mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-05-14 12:57:49 +00:00
feat(invite): be able to call numbers from the invite dialog (#2555)
* 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
This commit is contained in:
@@ -11,12 +11,21 @@ import { getInviteURL } from '../../base/connection';
|
||||
import { Dialog, hideDialog } from '../../base/dialog';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { MultiSelectAutocomplete } from '../../base/react';
|
||||
|
||||
import { invitePeopleAndChatRooms, searchDirectory } from '../functions';
|
||||
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.
|
||||
*/
|
||||
@@ -33,6 +42,11 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
_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.
|
||||
*/
|
||||
@@ -58,6 +72,16 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
_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.
|
||||
*/
|
||||
@@ -76,33 +100,7 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|
||||
_multiselect = null;
|
||||
|
||||
_resourceClient = {
|
||||
makeQuery: text => {
|
||||
const {
|
||||
_jwt,
|
||||
_peopleSearchQueryTypes,
|
||||
_peopleSearchUrl
|
||||
} = this.props; // eslint-disable-line no-invalid-this
|
||||
|
||||
return (
|
||||
searchDirectory(
|
||||
_peopleSearchUrl,
|
||||
_jwt,
|
||||
text,
|
||||
_peopleSearchQueryTypes));
|
||||
},
|
||||
|
||||
parseResults: response => response.map(user => {
|
||||
return {
|
||||
content: user.name,
|
||||
elemBefore: <Avatar
|
||||
size = 'medium'
|
||||
src = { user.avatar } />,
|
||||
item: user,
|
||||
value: user.id
|
||||
};
|
||||
})
|
||||
};
|
||||
_resourceClient: Object;
|
||||
|
||||
state = {
|
||||
/**
|
||||
@@ -116,6 +114,12 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -133,9 +137,17 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +165,7 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
&& !this.state.addToCallInProgress
|
||||
&& !this.state.addToCallError
|
||||
&& this._multiselect) {
|
||||
this._multiselect.clear();
|
||||
this._multiselect.setSelectedItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +175,69 @@ class AddPeopleDialog extends 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 = 'small'>
|
||||
{ this._renderUserInputForm() }
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -189,6 +252,45 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|| 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;
|
||||
|
||||
/**
|
||||
@@ -199,55 +301,279 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelectionChange(selectedItems) {
|
||||
const selectedIds = selectedItems.map(o => o.item);
|
||||
|
||||
this.setState({
|
||||
inviteItems: selectedIds
|
||||
inviteItems: selectedItems
|
||||
});
|
||||
}
|
||||
|
||||
_onSubmit: () => void;
|
||||
|
||||
/**
|
||||
* Handles the submit button action.
|
||||
* 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()) {
|
||||
this.setState({
|
||||
addToCallInProgress: true
|
||||
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));
|
||||
|
||||
});
|
||||
|
||||
const vrooms = this.state.inviteItems.filter(
|
||||
i => i.type === 'videosipgw');
|
||||
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);
|
||||
&& this.props.inviteVideoRooms(
|
||||
this.props._conference, vrooms);
|
||||
|
||||
invitePeopleAndChatRooms(
|
||||
this.props._inviteServiceUrl,
|
||||
this.props._inviteUrl,
|
||||
this.props._jwt,
|
||||
this.state.inviteItems.filter(
|
||||
i => i.type === 'user' || i.type === 'room'))
|
||||
.then(
|
||||
/* onFulfilled */ () => {
|
||||
this.setState({
|
||||
addToCallInProgress: false
|
||||
});
|
||||
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.props.hideDialog();
|
||||
},
|
||||
/* onRejected */ () => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,28 +620,16 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the input form.
|
||||
* Renders a telephone icon.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderUserInputForm() {
|
||||
const { t } = this.props;
|
||||
|
||||
_renderTelephoneIcon() {
|
||||
return (
|
||||
<div className = 'add-people-form-wrap'>
|
||||
{ this._renderErrorMessage() }
|
||||
<MultiSelectAutocomplete
|
||||
isDisabled
|
||||
= { this.state.addToCallInProgress || false }
|
||||
noMatchesFound = { t('addPeople.noResults') }
|
||||
onSelectionChange = { this._onSelectionChange }
|
||||
placeholder = { t('addPeople.searchPlaceholder') }
|
||||
ref = { this._setMultiSelectElement }
|
||||
resourceClient = { this._resourceClient }
|
||||
shouldFitContainer = { true }
|
||||
shouldFocus = { true } />
|
||||
</div>
|
||||
<span className = 'add-telephone-icon'>
|
||||
<i className = 'icon-telephone' />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,13 +655,19 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
* @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
|
||||
@@ -355,6 +675,7 @@ function _mapStateToProps(state) {
|
||||
|
||||
return {
|
||||
_conference: conference,
|
||||
_dialOutAuthUrl: dialOutAuthUrl,
|
||||
_inviteServiceUrl: inviteServiceUrl,
|
||||
_inviteUrl: getInviteURL(state),
|
||||
_jwt: state['features/base/jwt'].jwt,
|
||||
|
||||
Reference in New Issue
Block a user