* feat(invite): be able to call numbers from the invite dialog The major changes: - Remove DialOutDialog, its views, redux hooks, css, and images. Its main functionality has been moved into AddPeopleDialog. - Modify the AppPeopleDialog styling a bit so it is wider. - Add phone numbers to AddPeopleDialog search results. Phone numbers are validated in parallel with the request for people and then appended to the result. The validation includes an ajax to validate the number is recognized as dialable by the server. The trigger for the validation is essentially if the entered input is numbers only. - AddPeopleDialog holds onto the full object representation of an item selected in MultiSelectAutocomplete. This is so selected items can be removed on successful invite, leaving only unsuccessful items. - More granular error handling on invite so individual invitees can be removed from the selected items list. * squash: change load state, new regex for numbers * squash: change strings, auto prepend 1 if no country code, add reminders
691 lines
22 KiB
JavaScript
691 lines
22 KiB
JavaScript
// @flow
|
|
|
|
import Avatar from '@atlaskit/avatar';
|
|
import InlineMessage from '@atlaskit/inline-message';
|
|
import { Immutable } from 'nuclear-js';
|
|
import PropTypes from 'prop-types';
|
|
import React, { Component } from 'react';
|
|
import { connect } from 'react-redux';
|
|
|
|
import { getInviteURL } from '../../base/connection';
|
|
import { Dialog, hideDialog } from '../../base/dialog';
|
|
import { translate } from '../../base/i18n';
|
|
import { MultiSelectAutocomplete } from '../../base/react';
|
|
import { inviteVideoRooms } from '../../videosipgw';
|
|
|
|
import {
|
|
checkDialNumber,
|
|
invitePeopleAndChatRooms,
|
|
searchDirectory
|
|
} from '../functions';
|
|
|
|
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
|
|
|
declare var interfaceConfig: Object;
|
|
|
|
const isPhoneNumberRegex
|
|
= new RegExp(interfaceConfig.PHONE_NUMBER_REGEX || '^[0-9+()-\\s]*$');
|
|
|
|
/**
|
|
* The dialog that allows to invite people to the call.
|
|
*/
|
|
class AddPeopleDialog extends Component<*, *> {
|
|
/**
|
|
* {@code AddPeopleDialog}'s property types.
|
|
*
|
|
* @static
|
|
*/
|
|
static propTypes = {
|
|
/**
|
|
* The {@link JitsiMeetConference} which will be used to invite "room"
|
|
* participants through the SIP Jibri (Video SIP gateway).
|
|
*/
|
|
_conference: PropTypes.object,
|
|
|
|
/**
|
|
* The URL for validating if a phone number can be called.
|
|
*/
|
|
_dialOutAuthUrl: PropTypes.string,
|
|
|
|
/**
|
|
* The URL pointing to the service allowing for people invite.
|
|
*/
|
|
_inviteServiceUrl: PropTypes.string,
|
|
|
|
/**
|
|
* The url of the conference to invite people to.
|
|
*/
|
|
_inviteUrl: PropTypes.string,
|
|
|
|
/**
|
|
* The JWT token.
|
|
*/
|
|
_jwt: PropTypes.string,
|
|
|
|
/**
|
|
* The query types used when searching people.
|
|
*/
|
|
_peopleSearchQueryTypes: PropTypes.arrayOf(PropTypes.string),
|
|
|
|
/**
|
|
* The URL pointing to the service allowing for people search.
|
|
*/
|
|
_peopleSearchUrl: PropTypes.string,
|
|
|
|
/**
|
|
* Whether or not to show Add People functionality.
|
|
*/
|
|
enableAddPeople: PropTypes.bool,
|
|
|
|
/**
|
|
* Whether or not to show Dial Out functionality.
|
|
*/
|
|
enableDialOut: PropTypes.bool,
|
|
|
|
/**
|
|
* The function closing the dialog.
|
|
*/
|
|
hideDialog: PropTypes.func,
|
|
|
|
/**
|
|
* Used to invite video rooms.
|
|
*/
|
|
inviteVideoRooms: PropTypes.func,
|
|
|
|
/**
|
|
* Invoked to obtain translated strings.
|
|
*/
|
|
t: PropTypes.func
|
|
};
|
|
|
|
_multiselect = null;
|
|
|
|
_resourceClient: Object;
|
|
|
|
state = {
|
|
/**
|
|
* Indicating that an error occurred when adding people to the call.
|
|
*/
|
|
addToCallError: false,
|
|
|
|
/**
|
|
* Indicating that we're currently adding the new people to the
|
|
* call.
|
|
*/
|
|
addToCallInProgress: false,
|
|
|
|
|
|
// FIXME: Remove usage of Immutable. {@code MultiSelectAutocomplete}
|
|
// will default to having its internal implementation use a plain array
|
|
// if no {@link defaultValue} is passed in. As such is the case, this
|
|
// instance of Immutable.List gets overridden with an array on the first
|
|
// search.
|
|
/**
|
|
* The list of invite items.
|
|
*/
|
|
inviteItems: new Immutable.List()
|
|
};
|
|
|
|
/**
|
|
* Initializes a new {@code AddPeopleDialog} instance.
|
|
*
|
|
* @param {Object} props - The read-only properties with which the new
|
|
* instance is to be initialized.
|
|
*/
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
// Bind event handlers so they are only bound once per instance.
|
|
this._isAddDisabled = this._isAddDisabled.bind(this);
|
|
this._onItemSelected = this._onItemSelected.bind(this);
|
|
this._onSelectionChange = this._onSelectionChange.bind(this);
|
|
this._onSubmit = this._onSubmit.bind(this);
|
|
this._parseQueryResults = this._parseQueryResults.bind(this);
|
|
this._query = this._query.bind(this);
|
|
this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
|
|
|
|
this._resourceClient = {
|
|
makeQuery: this._query,
|
|
parseResults: this._parseQueryResults
|
|
};
|
|
}
|
|
|
|
/**
|
|
* React Component method that executes once component is updated.
|
|
*
|
|
* @param {Object} prevState - The state object before the update.
|
|
* @returns {void}
|
|
*/
|
|
componentDidUpdate(prevState) {
|
|
/**
|
|
* Clears selected items from the multi select component on successful
|
|
* invite.
|
|
*/
|
|
if (prevState.addToCallError
|
|
&& !this.state.addToCallInProgress
|
|
&& !this.state.addToCallError
|
|
&& this._multiselect) {
|
|
this._multiselect.setSelectedItems([]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the content of this component.
|
|
*
|
|
* @returns {ReactElement}
|
|
*/
|
|
render() {
|
|
const { enableAddPeople, enableDialOut, t } = this.props;
|
|
let isMultiSelectDisabled = this.state.addToCallInProgress || false;
|
|
let placeholder;
|
|
let loadingMessage;
|
|
let noMatches;
|
|
|
|
if (enableAddPeople && enableDialOut) {
|
|
loadingMessage = 'addPeople.loading';
|
|
noMatches = 'addPeople.noResults';
|
|
placeholder = 'addPeople.searchPeopleAndNumbers';
|
|
} else if (enableAddPeople) {
|
|
loadingMessage = 'addPeople.loadingPeople';
|
|
noMatches = 'addPeople.noResults';
|
|
placeholder = 'addPeople.searchPeople';
|
|
} else if (enableDialOut) {
|
|
loadingMessage = 'addPeople.loadingNumber';
|
|
noMatches = 'addPeople.noValidNumbers';
|
|
placeholder = 'addPeople.searchNumbers';
|
|
} else {
|
|
isMultiSelectDisabled = true;
|
|
noMatches = 'addPeople.noResults';
|
|
placeholder = 'addPeople.disabled';
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
okDisabled = { this._isAddDisabled() }
|
|
okTitleKey = 'addPeople.add'
|
|
onSubmit = { this._onSubmit }
|
|
titleKey = 'addPeople.title'
|
|
width = 'medium'>
|
|
<div className = 'add-people-form-wrap'>
|
|
{ this._renderErrorMessage() }
|
|
<MultiSelectAutocomplete
|
|
isDisabled = { isMultiSelectDisabled }
|
|
loadingMessage = { t(loadingMessage) }
|
|
noMatchesFound = { t(noMatches) }
|
|
onItemSelected = { this._onItemSelected }
|
|
onSelectionChange = { this._onSelectionChange }
|
|
placeholder = { t(placeholder) }
|
|
ref = { this._setMultiSelectElement }
|
|
resourceClient = { this._resourceClient }
|
|
shouldFitContainer = { true }
|
|
shouldFocus = { true } />
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
_getDigitsOnly: (string) => string;
|
|
|
|
/**
|
|
* Removes all non-numeric characters from a string.
|
|
*
|
|
* @param {string} text - The string from which to remove all characters
|
|
* except numbers.
|
|
* @private
|
|
* @returns {string} A string with only numbers.
|
|
*/
|
|
_getDigitsOnly(text = '') {
|
|
return text.replace(/\D/g, '');
|
|
}
|
|
|
|
_isAddDisabled: () => boolean;
|
|
|
|
/**
|
|
* Indicates if the Add button should be disabled.
|
|
*
|
|
* @private
|
|
* @returns {boolean} - True to indicate that the Add button should
|
|
* be disabled, false otherwise.
|
|
*/
|
|
_isAddDisabled() {
|
|
return !this.state.inviteItems.length
|
|
|| this.state.addToCallInProgress;
|
|
}
|
|
|
|
_isMaybeAPhoneNumber: (string) => boolean;
|
|
|
|
/**
|
|
* Checks whether a string looks like it could be for a phone number.
|
|
*
|
|
* @param {string} text - The text to check whether or not it could be a
|
|
* phone number.
|
|
* @private
|
|
* @returns {boolean} True if the string looks like it could be a phone
|
|
* number.
|
|
*/
|
|
_isMaybeAPhoneNumber(text) {
|
|
if (!isPhoneNumberRegex.test(text)) {
|
|
return false;
|
|
}
|
|
|
|
const digits = this._getDigitsOnly(text);
|
|
|
|
return Boolean(digits.length);
|
|
}
|
|
|
|
_onItemSelected: (Object) => Object;
|
|
|
|
/**
|
|
* Callback invoked when a selection has been made but before it has been
|
|
* set as selected.
|
|
*
|
|
* @param {Object} item - The item that has just been selected.
|
|
* @private
|
|
* @returns {Object} The item to display as selected in the input.
|
|
*/
|
|
_onItemSelected(item) {
|
|
if (item.item.type === 'phone') {
|
|
item.content = item.item.number;
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
_onSelectionChange: (Map<*, *>) => void;
|
|
|
|
/**
|
|
* Handles a selection change.
|
|
*
|
|
* @param {Map} selectedItems - The list of selected items.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_onSelectionChange(selectedItems) {
|
|
this.setState({
|
|
inviteItems: selectedItems
|
|
});
|
|
}
|
|
|
|
_onSubmit: () => void;
|
|
|
|
/**
|
|
* Invite people and numbers to the conference. The logic works by inviting
|
|
* numbers, people/rooms, and videosipgw in parallel. All invitees are
|
|
* stored in an array. As each invite succeeds, the invitee is removed
|
|
* from the array. After all invites finish, close the modal if there are
|
|
* no invites left to send. If any are left, that means an invite failed
|
|
* and an error state should display.
|
|
*
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_onSubmit() {
|
|
if (this._isAddDisabled()) {
|
|
return;
|
|
}
|
|
|
|
this.setState({
|
|
addToCallInProgress: true
|
|
});
|
|
|
|
let allInvitePromises = [];
|
|
let invitesLeftToSend = [
|
|
...this.state.inviteItems
|
|
];
|
|
|
|
// First create all promises for dialing out.
|
|
if (this.props.enableDialOut && this.props._conference) {
|
|
const phoneNumbers = invitesLeftToSend.filter(
|
|
({ item }) => item.type === 'phone');
|
|
|
|
// For each number, dial out. On success, remove the number from
|
|
// {@link invitesLeftToSend}.
|
|
const phoneInvitePromises = phoneNumbers.map(number => {
|
|
const numberToInvite = this._getDigitsOnly(number.item.number);
|
|
|
|
return this.props._conference.dial(numberToInvite)
|
|
.then(() => {
|
|
invitesLeftToSend
|
|
= invitesLeftToSend.filter(invite =>
|
|
invite !== number);
|
|
})
|
|
.catch(error => logger.error(
|
|
'Error inviting phone number:', error));
|
|
|
|
});
|
|
|
|
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
|
|
}
|
|
|
|
if (this.props.enableAddPeople) {
|
|
const usersAndRooms = invitesLeftToSend.filter(i =>
|
|
i.item.type === 'user' || i.item.type === 'room')
|
|
.map(i => i.item);
|
|
|
|
if (usersAndRooms.length) {
|
|
// Send a request to invite all the rooms and users. On success,
|
|
// filter all rooms and users from {@link invitesLeftToSend}.
|
|
const peopleInvitePromise = invitePeopleAndChatRooms(
|
|
this.props._inviteServiceUrl,
|
|
this.props._inviteUrl,
|
|
this.props._jwt,
|
|
usersAndRooms)
|
|
.then(() => {
|
|
invitesLeftToSend = invitesLeftToSend.filter(i =>
|
|
i.item.type !== 'user' && i.item.type !== 'room');
|
|
})
|
|
.catch(error => logger.error(
|
|
'Error inviting people:', error));
|
|
|
|
allInvitePromises.push(peopleInvitePromise);
|
|
}
|
|
|
|
// Sipgw calls are fire and forget. Invite them to the conference
|
|
// then immediately remove them from {@link invitesLeftToSend}.
|
|
const vrooms = invitesLeftToSend.filter(i =>
|
|
i.item.type === 'videosipgw')
|
|
.map(i => i.item);
|
|
|
|
this.props._conference
|
|
&& vrooms.length > 0
|
|
&& this.props.inviteVideoRooms(
|
|
this.props._conference, vrooms);
|
|
|
|
invitesLeftToSend = invitesLeftToSend.filter(i =>
|
|
i.item.type !== 'videosipgw');
|
|
}
|
|
|
|
Promise.all(allInvitePromises)
|
|
.then(() => {
|
|
// If any invites are left that means something failed to send
|
|
// so treat it as an error.
|
|
if (invitesLeftToSend.length) {
|
|
logger.error(`${invitesLeftToSend.length} invites failed`);
|
|
|
|
this.setState({
|
|
addToCallInProgress: false,
|
|
addToCallError: true
|
|
});
|
|
|
|
if (this._multiselect) {
|
|
this._multiselect.setSelectedItems(invitesLeftToSend);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.setState({
|
|
addToCallInProgress: false
|
|
});
|
|
|
|
this.props.hideDialog();
|
|
});
|
|
}
|
|
|
|
_parseQueryResults: (Array<Object>, string) => Array<Object>;
|
|
|
|
/**
|
|
* Processes results from requesting available numbers and people by munging
|
|
* each result into a format {@code MultiSelectAutocomplete} can use for
|
|
* display.
|
|
*
|
|
* @param {Array} response - The response object from the server for the
|
|
* query.
|
|
* @private
|
|
* @returns {Object[]} Configuration objects for items to display in the
|
|
* search autocomplete.
|
|
*/
|
|
_parseQueryResults(response = []) {
|
|
const { t } = this.props;
|
|
const users = response.filter(item => item.type !== 'phone');
|
|
const userDisplayItems = users.map(user => {
|
|
return {
|
|
content: user.name,
|
|
elemBefore: <Avatar
|
|
size = 'medium'
|
|
src = { user.avatar } />,
|
|
item: user,
|
|
tag: {
|
|
elemBefore: <Avatar
|
|
size = 'xsmall'
|
|
src = { user.avatar } />
|
|
},
|
|
value: user.id
|
|
};
|
|
});
|
|
|
|
const numbers = response.filter(item => item.type === 'phone');
|
|
const telephoneIcon = this._renderTelephoneIcon();
|
|
|
|
const numberDisplayItems = numbers.map(number => {
|
|
const numberNotAllowedMessage
|
|
= number.allowed ? '' : t('addPeople.countryNotSupported');
|
|
const countryCodeReminder = number.showCountryCodeReminder
|
|
? t('addPeople.countryReminder') : '';
|
|
const description
|
|
= `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
|
|
|
|
return {
|
|
filterValues: [
|
|
number.originalEntry,
|
|
number.number
|
|
],
|
|
content: t('addPeople.telephone', { number: number.number }),
|
|
description,
|
|
isDisabled: !number.allowed,
|
|
elemBefore: telephoneIcon,
|
|
item: number,
|
|
tag: {
|
|
elemBefore: telephoneIcon
|
|
},
|
|
value: number.number
|
|
};
|
|
});
|
|
|
|
return [
|
|
...userDisplayItems,
|
|
...numberDisplayItems
|
|
];
|
|
}
|
|
|
|
_query: (string) => Promise<Array<Object>>;
|
|
|
|
/**
|
|
* Performs a people and phone number search request.
|
|
*
|
|
* @param {string} query - The search text.
|
|
* @private
|
|
* @returns {Promise}
|
|
*/
|
|
_query(query = '') {
|
|
const text = query.trim();
|
|
const {
|
|
_dialOutAuthUrl,
|
|
_jwt,
|
|
_peopleSearchQueryTypes,
|
|
_peopleSearchUrl
|
|
} = this.props;
|
|
|
|
let peopleSearchPromise;
|
|
|
|
if (this.props.enableAddPeople) {
|
|
peopleSearchPromise = searchDirectory(
|
|
_peopleSearchUrl,
|
|
_jwt,
|
|
text,
|
|
_peopleSearchQueryTypes);
|
|
} else {
|
|
peopleSearchPromise = Promise.resolve([]);
|
|
}
|
|
|
|
|
|
const hasCountryCode = text.startsWith('+');
|
|
let phoneNumberPromise;
|
|
|
|
if (this.props.enableDialOut && this._isMaybeAPhoneNumber(text)) {
|
|
let numberToVerify = text;
|
|
|
|
// When the number to verify does not start with a +, we assume no
|
|
// proper country code has been entered. In such a case, prepend 1
|
|
// for the country code. The service currently takes care of
|
|
// prepending the +.
|
|
if (!hasCountryCode && !text.startsWith('1')) {
|
|
numberToVerify = `1${numberToVerify}`;
|
|
}
|
|
|
|
// The validation service works properly when the query is digits
|
|
// only so ensure only digits get sent.
|
|
numberToVerify = this._getDigitsOnly(numberToVerify);
|
|
|
|
phoneNumberPromise
|
|
= checkDialNumber(numberToVerify, _dialOutAuthUrl);
|
|
} else {
|
|
phoneNumberPromise = Promise.resolve({});
|
|
}
|
|
|
|
return Promise.all([ peopleSearchPromise, phoneNumberPromise ])
|
|
.then(([ peopleResults, phoneResults ]) => {
|
|
const results = [
|
|
...peopleResults
|
|
];
|
|
|
|
/**
|
|
* This check for phone results is for the day the call to
|
|
* searching people might return phone results as well. When
|
|
* that day comes this check will make it so the server checks
|
|
* are honored and the local appending of the number is not
|
|
* done. The local appending of the phone number can then be
|
|
* cleaned up when convenient.
|
|
*/
|
|
const hasPhoneResult = peopleResults.find(
|
|
result => result.type === 'phone');
|
|
|
|
if (!hasPhoneResult
|
|
&& typeof phoneResults.allow === 'boolean') {
|
|
results.push({
|
|
allowed: phoneResults.allow,
|
|
country: phoneResults.country,
|
|
type: 'phone',
|
|
number: phoneResults.phone,
|
|
originalEntry: text,
|
|
showCountryCodeReminder: !hasCountryCode
|
|
});
|
|
}
|
|
|
|
return results;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders the error message if the add doesn't succeed.
|
|
*
|
|
* @private
|
|
* @returns {ReactElement|null}
|
|
*/
|
|
_renderErrorMessage() {
|
|
if (!this.state.addToCallError) {
|
|
return null;
|
|
}
|
|
|
|
const { t } = this.props;
|
|
const supportString = t('inlineDialogFailure.supportMsg');
|
|
const supportLink = interfaceConfig.SUPPORT_URL;
|
|
const supportLinkContent
|
|
= ( // eslint-disable-line no-extra-parens
|
|
<span>
|
|
<span>
|
|
{ supportString.padEnd(supportString.length + 1) }
|
|
</span>
|
|
<span>
|
|
<a
|
|
href = { supportLink }
|
|
rel = 'noopener noreferrer'
|
|
target = '_blank'>
|
|
{ t('inlineDialogFailure.support') }
|
|
</a>
|
|
</span>
|
|
<span>.</span>
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<div className = 'modal-dialog-form-error'>
|
|
<InlineMessage
|
|
title = { t('addPeople.failedToAdd') }
|
|
type = 'error'>
|
|
{ supportLinkContent }
|
|
</InlineMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders a telephone icon.
|
|
*
|
|
* @private
|
|
* @returns {ReactElement}
|
|
*/
|
|
_renderTelephoneIcon() {
|
|
return (
|
|
<span className = 'add-telephone-icon'>
|
|
<i className = 'icon-telephone' />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
_setMultiSelectElement: (React$ElementRef<*> | null) => mixed;
|
|
|
|
/**
|
|
* Sets the instance variable for the multi select component
|
|
* element so it can be accessed directly.
|
|
*
|
|
* @param {Object} element - The DOM element for the component's dialog.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_setMultiSelectElement(element) {
|
|
this._multiselect = element;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maps (parts of) the Redux state to the associated
|
|
* {@code AddPeopleDialog}'s props.
|
|
*
|
|
* @param {Object} state - The Redux state.
|
|
* @private
|
|
* @returns {{
|
|
* _conference: Object,
|
|
* _dialOutAuthUrl: string,
|
|
* _inviteServiceUrl: string,
|
|
* _inviteUrl: string,
|
|
* _jwt: string,
|
|
* _peopleSearchQueryTypes: Array<string>,
|
|
* _peopleSearchUrl: string
|
|
* }}
|
|
*/
|
|
function _mapStateToProps(state) {
|
|
const { conference } = state['features/base/conference'];
|
|
const {
|
|
dialOutAuthUrl,
|
|
inviteServiceUrl,
|
|
peopleSearchQueryTypes,
|
|
peopleSearchUrl
|
|
} = state['features/base/config'];
|
|
|
|
return {
|
|
_conference: conference,
|
|
_dialOutAuthUrl: dialOutAuthUrl,
|
|
_inviteServiceUrl: inviteServiceUrl,
|
|
_inviteUrl: getInviteURL(state),
|
|
_jwt: state['features/base/jwt'].jwt,
|
|
_peopleSearchQueryTypes: peopleSearchQueryTypes,
|
|
_peopleSearchUrl: peopleSearchUrl
|
|
};
|
|
}
|
|
|
|
export default translate(connect(_mapStateToProps, {
|
|
hideDialog,
|
|
inviteVideoRooms })(
|
|
AddPeopleDialog));
|