// @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 (
{ this._renderErrorMessage() }
); } _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, string) => Array; /** * 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: , item: user, tag: { elemBefore: }, 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>; /** * 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 { supportString.padEnd(supportString.length + 1) } { t('inlineDialogFailure.support') } . ); return (
{ supportLinkContent }
); } /** * Renders a telephone icon. * * @private * @returns {ReactElement} */ _renderTelephoneIcon() { return ( ); } _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, * _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));