You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AddPeopleDialog.web.js 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. // @flow
  2. import Avatar from '@atlaskit/avatar';
  3. import InlineMessage from '@atlaskit/inline-message';
  4. import PropTypes from 'prop-types';
  5. import React, { Component } from 'react';
  6. import { connect } from 'react-redux';
  7. import { createInviteDialogEvent, sendAnalytics } from '../../analytics';
  8. import { getInviteURL } from '../../base/connection';
  9. import { Dialog, hideDialog } from '../../base/dialog';
  10. import { translate } from '../../base/i18n';
  11. import { MultiSelectAutocomplete } from '../../base/react';
  12. import { inviteVideoRooms } from '../../videosipgw';
  13. import {
  14. checkDialNumber,
  15. invitePeopleAndChatRooms,
  16. searchDirectory
  17. } from '../functions';
  18. const logger = require('jitsi-meet-logger').getLogger(__filename);
  19. declare var interfaceConfig: Object;
  20. const isPhoneNumberRegex
  21. = new RegExp(interfaceConfig.PHONE_NUMBER_REGEX || '^[0-9+()-\\s]*$');
  22. /**
  23. * The dialog that allows to invite people to the call.
  24. */
  25. class AddPeopleDialog extends Component<*, *> {
  26. /**
  27. * {@code AddPeopleDialog}'s property types.
  28. *
  29. * @static
  30. */
  31. static propTypes = {
  32. /**
  33. * The {@link JitsiMeetConference} which will be used to invite "room"
  34. * participants through the SIP Jibri (Video SIP gateway).
  35. */
  36. _conference: PropTypes.object,
  37. /**
  38. * The URL for validating if a phone number can be called.
  39. */
  40. _dialOutAuthUrl: PropTypes.string,
  41. /**
  42. * The URL pointing to the service allowing for people invite.
  43. */
  44. _inviteServiceUrl: PropTypes.string,
  45. /**
  46. * The url of the conference to invite people to.
  47. */
  48. _inviteUrl: PropTypes.string,
  49. /**
  50. * The JWT token.
  51. */
  52. _jwt: PropTypes.string,
  53. /**
  54. * The query types used when searching people.
  55. */
  56. _peopleSearchQueryTypes: PropTypes.arrayOf(PropTypes.string),
  57. /**
  58. * The URL pointing to the service allowing for people search.
  59. */
  60. _peopleSearchUrl: PropTypes.string,
  61. /**
  62. * Whether or not to show Add People functionality.
  63. */
  64. enableAddPeople: PropTypes.bool,
  65. /**
  66. * Whether or not to show Dial Out functionality.
  67. */
  68. enableDialOut: PropTypes.bool,
  69. /**
  70. * The function closing the dialog.
  71. */
  72. hideDialog: PropTypes.func,
  73. /**
  74. * Used to invite video rooms.
  75. */
  76. inviteVideoRooms: PropTypes.func,
  77. /**
  78. * Invoked to obtain translated strings.
  79. */
  80. t: PropTypes.func
  81. };
  82. _multiselect = null;
  83. _resourceClient: Object;
  84. state = {
  85. /**
  86. * Indicating that an error occurred when adding people to the call.
  87. */
  88. addToCallError: false,
  89. /**
  90. * Indicating that we're currently adding the new people to the
  91. * call.
  92. */
  93. addToCallInProgress: false,
  94. /**
  95. * The list of invite items.
  96. */
  97. inviteItems: []
  98. };
  99. /**
  100. * Initializes a new {@code AddPeopleDialog} instance.
  101. *
  102. * @param {Object} props - The read-only properties with which the new
  103. * instance is to be initialized.
  104. */
  105. constructor(props) {
  106. super(props);
  107. // Bind event handlers so they are only bound once per instance.
  108. this._isAddDisabled = this._isAddDisabled.bind(this);
  109. this._onItemSelected = this._onItemSelected.bind(this);
  110. this._onSelectionChange = this._onSelectionChange.bind(this);
  111. this._onSubmit = this._onSubmit.bind(this);
  112. this._parseQueryResults = this._parseQueryResults.bind(this);
  113. this._query = this._query.bind(this);
  114. this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
  115. this._resourceClient = {
  116. makeQuery: this._query,
  117. parseResults: this._parseQueryResults
  118. };
  119. }
  120. /**
  121. * Sends an analytics event to record the dialog has been shown.
  122. *
  123. * @inheritdoc
  124. * @returns {void}
  125. */
  126. componentDidMount() {
  127. sendAnalytics(createInviteDialogEvent(
  128. 'invite.dialog.opened', 'dialog'));
  129. }
  130. /**
  131. * React Component method that executes once component is updated.
  132. *
  133. * @param {Object} prevState - The state object before the update.
  134. * @returns {void}
  135. */
  136. componentDidUpdate(prevState) {
  137. /**
  138. * Clears selected items from the multi select component on successful
  139. * invite.
  140. */
  141. if (prevState.addToCallError
  142. && !this.state.addToCallInProgress
  143. && !this.state.addToCallError
  144. && this._multiselect) {
  145. this._multiselect.setSelectedItems([]);
  146. }
  147. }
  148. /**
  149. * Sends an analytics event to record the dialog has been closed.
  150. *
  151. * @inheritdoc
  152. * @returns {void}
  153. */
  154. componentWillUnmount() {
  155. sendAnalytics(createInviteDialogEvent(
  156. 'invite.dialog.closed', 'dialog'));
  157. }
  158. /**
  159. * Renders the content of this component.
  160. *
  161. * @returns {ReactElement}
  162. */
  163. render() {
  164. const { enableAddPeople, enableDialOut, t } = this.props;
  165. let isMultiSelectDisabled = this.state.addToCallInProgress || false;
  166. let placeholder;
  167. let loadingMessage;
  168. let noMatches;
  169. if (enableAddPeople && enableDialOut) {
  170. loadingMessage = 'addPeople.loading';
  171. noMatches = 'addPeople.noResults';
  172. placeholder = 'addPeople.searchPeopleAndNumbers';
  173. } else if (enableAddPeople) {
  174. loadingMessage = 'addPeople.loadingPeople';
  175. noMatches = 'addPeople.noResults';
  176. placeholder = 'addPeople.searchPeople';
  177. } else if (enableDialOut) {
  178. loadingMessage = 'addPeople.loadingNumber';
  179. noMatches = 'addPeople.noValidNumbers';
  180. placeholder = 'addPeople.searchNumbers';
  181. } else {
  182. isMultiSelectDisabled = true;
  183. noMatches = 'addPeople.noResults';
  184. placeholder = 'addPeople.disabled';
  185. }
  186. return (
  187. <Dialog
  188. okDisabled = { this._isAddDisabled() }
  189. okTitleKey = 'addPeople.add'
  190. onSubmit = { this._onSubmit }
  191. titleKey = 'addPeople.title'
  192. width = 'medium'>
  193. <div className = 'add-people-form-wrap'>
  194. { this._renderErrorMessage() }
  195. <MultiSelectAutocomplete
  196. isDisabled = { isMultiSelectDisabled }
  197. loadingMessage = { t(loadingMessage) }
  198. noMatchesFound = { t(noMatches) }
  199. onItemSelected = { this._onItemSelected }
  200. onSelectionChange = { this._onSelectionChange }
  201. placeholder = { t(placeholder) }
  202. ref = { this._setMultiSelectElement }
  203. resourceClient = { this._resourceClient }
  204. shouldFitContainer = { true }
  205. shouldFocus = { true } />
  206. </div>
  207. </Dialog>
  208. );
  209. }
  210. _getDigitsOnly: (string) => string;
  211. /**
  212. * Removes all non-numeric characters from a string.
  213. *
  214. * @param {string} text - The string from which to remove all characters
  215. * except numbers.
  216. * @private
  217. * @returns {string} A string with only numbers.
  218. */
  219. _getDigitsOnly(text = '') {
  220. return text.replace(/\D/g, '');
  221. }
  222. /**
  223. * Helper for determining how many of each type of user is being invited.
  224. * Used for logging and sending analytics related to invites.
  225. *
  226. * @param {Array} inviteItems - An array with the invite items, as created
  227. * in {@link _parseQueryResults}.
  228. * @private
  229. * @returns {Object} An object with keys as user types and values as the
  230. * number of invites for that type.
  231. */
  232. _getInviteTypeCounts(inviteItems = []) {
  233. const inviteTypeCounts = {};
  234. inviteItems.forEach(i => {
  235. const type = i.item.type;
  236. if (!inviteTypeCounts[type]) {
  237. inviteTypeCounts[type] = 0;
  238. }
  239. inviteTypeCounts[type]++;
  240. });
  241. return inviteTypeCounts;
  242. }
  243. _isAddDisabled: () => boolean;
  244. /**
  245. * Indicates if the Add button should be disabled.
  246. *
  247. * @private
  248. * @returns {boolean} - True to indicate that the Add button should
  249. * be disabled, false otherwise.
  250. */
  251. _isAddDisabled() {
  252. return !this.state.inviteItems.length
  253. || this.state.addToCallInProgress;
  254. }
  255. _isMaybeAPhoneNumber: (string) => boolean;
  256. /**
  257. * Checks whether a string looks like it could be for a phone number.
  258. *
  259. * @param {string} text - The text to check whether or not it could be a
  260. * phone number.
  261. * @private
  262. * @returns {boolean} True if the string looks like it could be a phone
  263. * number.
  264. */
  265. _isMaybeAPhoneNumber(text) {
  266. if (!isPhoneNumberRegex.test(text)) {
  267. return false;
  268. }
  269. const digits = this._getDigitsOnly(text);
  270. return Boolean(digits.length);
  271. }
  272. _onItemSelected: (Object) => Object;
  273. /**
  274. * Callback invoked when a selection has been made but before it has been
  275. * set as selected.
  276. *
  277. * @param {Object} item - The item that has just been selected.
  278. * @private
  279. * @returns {Object} The item to display as selected in the input.
  280. */
  281. _onItemSelected(item) {
  282. if (item.item.type === 'phone') {
  283. item.content = item.item.number;
  284. }
  285. return item;
  286. }
  287. _onSelectionChange: (Map<*, *>) => void;
  288. /**
  289. * Handles a selection change.
  290. *
  291. * @param {Map} selectedItems - The list of selected items.
  292. * @private
  293. * @returns {void}
  294. */
  295. _onSelectionChange(selectedItems) {
  296. this.setState({
  297. inviteItems: selectedItems
  298. });
  299. }
  300. _onSubmit: () => void;
  301. /**
  302. * Invite people and numbers to the conference. The logic works by inviting
  303. * numbers, people/rooms, and videosipgw in parallel. All invitees are
  304. * stored in an array. As each invite succeeds, the invitee is removed
  305. * from the array. After all invites finish, close the modal if there are
  306. * no invites left to send. If any are left, that means an invite failed
  307. * and an error state should display.
  308. *
  309. * @private
  310. * @returns {void}
  311. */
  312. _onSubmit() {
  313. const inviteTypeCounts
  314. = this._getInviteTypeCounts(this.state.inviteItems);
  315. sendAnalytics(createInviteDialogEvent(
  316. 'clicked', 'inviteButton', {
  317. ...inviteTypeCounts,
  318. inviteAllowed: this._isAddDisabled()
  319. }));
  320. if (this._isAddDisabled()) {
  321. return;
  322. }
  323. this.setState({
  324. addToCallInProgress: true
  325. });
  326. let allInvitePromises = [];
  327. let invitesLeftToSend = [
  328. ...this.state.inviteItems
  329. ];
  330. // First create all promises for dialing out.
  331. if (this.props.enableDialOut && this.props._conference) {
  332. const phoneNumbers = invitesLeftToSend.filter(
  333. ({ item }) => item.type === 'phone');
  334. // For each number, dial out. On success, remove the number from
  335. // {@link invitesLeftToSend}.
  336. const phoneInvitePromises = phoneNumbers.map(number => {
  337. const numberToInvite = this._getDigitsOnly(number.item.number);
  338. return this.props._conference.dial(numberToInvite)
  339. .then(() => {
  340. invitesLeftToSend
  341. = invitesLeftToSend.filter(invite =>
  342. invite !== number);
  343. })
  344. .catch(error => logger.error(
  345. 'Error inviting phone number:', error));
  346. });
  347. allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
  348. }
  349. if (this.props.enableAddPeople) {
  350. const usersAndRooms = invitesLeftToSend.filter(i =>
  351. i.item.type === 'user' || i.item.type === 'room')
  352. .map(i => i.item);
  353. if (usersAndRooms.length) {
  354. // Send a request to invite all the rooms and users. On success,
  355. // filter all rooms and users from {@link invitesLeftToSend}.
  356. const peopleInvitePromise = invitePeopleAndChatRooms(
  357. this.props._inviteServiceUrl,
  358. this.props._inviteUrl,
  359. this.props._jwt,
  360. usersAndRooms)
  361. .then(() => {
  362. invitesLeftToSend = invitesLeftToSend.filter(i =>
  363. i.item.type !== 'user' && i.item.type !== 'room');
  364. })
  365. .catch(error => logger.error(
  366. 'Error inviting people:', error));
  367. allInvitePromises.push(peopleInvitePromise);
  368. }
  369. // Sipgw calls are fire and forget. Invite them to the conference
  370. // then immediately remove them from {@link invitesLeftToSend}.
  371. const vrooms = invitesLeftToSend.filter(i =>
  372. i.item.type === 'videosipgw')
  373. .map(i => i.item);
  374. this.props._conference
  375. && vrooms.length > 0
  376. && this.props.inviteVideoRooms(
  377. this.props._conference, vrooms);
  378. invitesLeftToSend = invitesLeftToSend.filter(i =>
  379. i.item.type !== 'videosipgw');
  380. }
  381. Promise.all(allInvitePromises)
  382. .then(() => {
  383. // If any invites are left that means something failed to send
  384. // so treat it as an error.
  385. if (invitesLeftToSend.length) {
  386. const erroredInviteTypeCounts
  387. = this._getInviteTypeCounts(invitesLeftToSend);
  388. logger.error(`${invitesLeftToSend.length} invites failed`,
  389. erroredInviteTypeCounts);
  390. sendAnalytics(createInviteDialogEvent(
  391. 'error', 'invite', {
  392. ...erroredInviteTypeCounts
  393. }));
  394. this.setState({
  395. addToCallInProgress: false,
  396. addToCallError: true
  397. });
  398. if (this._multiselect) {
  399. this._multiselect.setSelectedItems(invitesLeftToSend);
  400. }
  401. return;
  402. }
  403. this.setState({
  404. addToCallInProgress: false
  405. });
  406. this.props.hideDialog();
  407. });
  408. }
  409. _parseQueryResults: (Array<Object>, string) => Array<Object>;
  410. /**
  411. * Processes results from requesting available numbers and people by munging
  412. * each result into a format {@code MultiSelectAutocomplete} can use for
  413. * display.
  414. *
  415. * @param {Array} response - The response object from the server for the
  416. * query.
  417. * @private
  418. * @returns {Object[]} Configuration objects for items to display in the
  419. * search autocomplete.
  420. */
  421. _parseQueryResults(response = []) {
  422. const { t } = this.props;
  423. const users = response.filter(item => item.type !== 'phone');
  424. const userDisplayItems = users.map(user => {
  425. return {
  426. content: user.name,
  427. elemBefore: <Avatar
  428. size = 'medium'
  429. src = { user.avatar } />,
  430. item: user,
  431. tag: {
  432. elemBefore: <Avatar
  433. size = 'xsmall'
  434. src = { user.avatar } />
  435. },
  436. value: user.id
  437. };
  438. });
  439. const numbers = response.filter(item => item.type === 'phone');
  440. const telephoneIcon = this._renderTelephoneIcon();
  441. const numberDisplayItems = numbers.map(number => {
  442. const numberNotAllowedMessage
  443. = number.allowed ? '' : t('addPeople.countryNotSupported');
  444. const countryCodeReminder = number.showCountryCodeReminder
  445. ? t('addPeople.countryReminder') : '';
  446. const description
  447. = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
  448. return {
  449. filterValues: [
  450. number.originalEntry,
  451. number.number
  452. ],
  453. content: t('addPeople.telephone', { number: number.number }),
  454. description,
  455. isDisabled: !number.allowed,
  456. elemBefore: telephoneIcon,
  457. item: number,
  458. tag: {
  459. elemBefore: telephoneIcon
  460. },
  461. value: number.number
  462. };
  463. });
  464. return [
  465. ...userDisplayItems,
  466. ...numberDisplayItems
  467. ];
  468. }
  469. _query: (string) => Promise<Array<Object>>;
  470. /**
  471. * Performs a people and phone number search request.
  472. *
  473. * @param {string} query - The search text.
  474. * @private
  475. * @returns {Promise}
  476. */
  477. _query(query = '') {
  478. const text = query.trim();
  479. const {
  480. _dialOutAuthUrl,
  481. _jwt,
  482. _peopleSearchQueryTypes,
  483. _peopleSearchUrl
  484. } = this.props;
  485. let peopleSearchPromise;
  486. if (this.props.enableAddPeople && text) {
  487. peopleSearchPromise = searchDirectory(
  488. _peopleSearchUrl,
  489. _jwt,
  490. text,
  491. _peopleSearchQueryTypes);
  492. } else {
  493. peopleSearchPromise = Promise.resolve([]);
  494. }
  495. const hasCountryCode = text.startsWith('+');
  496. let phoneNumberPromise;
  497. if (this.props.enableDialOut && this._isMaybeAPhoneNumber(text)) {
  498. let numberToVerify = text;
  499. // When the number to verify does not start with a +, we assume no
  500. // proper country code has been entered. In such a case, prepend 1
  501. // for the country code. The service currently takes care of
  502. // prepending the +.
  503. if (!hasCountryCode && !text.startsWith('1')) {
  504. numberToVerify = `1${numberToVerify}`;
  505. }
  506. // The validation service works properly when the query is digits
  507. // only so ensure only digits get sent.
  508. numberToVerify = this._getDigitsOnly(numberToVerify);
  509. phoneNumberPromise
  510. = checkDialNumber(numberToVerify, _dialOutAuthUrl);
  511. } else {
  512. phoneNumberPromise = Promise.resolve({});
  513. }
  514. return Promise.all([ peopleSearchPromise, phoneNumberPromise ])
  515. .then(([ peopleResults, phoneResults ]) => {
  516. const results = [
  517. ...peopleResults
  518. ];
  519. /**
  520. * This check for phone results is for the day the call to
  521. * searching people might return phone results as well. When
  522. * that day comes this check will make it so the server checks
  523. * are honored and the local appending of the number is not
  524. * done. The local appending of the phone number can then be
  525. * cleaned up when convenient.
  526. */
  527. const hasPhoneResult = peopleResults.find(
  528. result => result.type === 'phone');
  529. if (!hasPhoneResult
  530. && typeof phoneResults.allow === 'boolean') {
  531. results.push({
  532. allowed: phoneResults.allow,
  533. country: phoneResults.country,
  534. type: 'phone',
  535. number: phoneResults.phone,
  536. originalEntry: text,
  537. showCountryCodeReminder: !hasCountryCode
  538. });
  539. }
  540. return results;
  541. });
  542. }
  543. /**
  544. * Renders the error message if the add doesn't succeed.
  545. *
  546. * @private
  547. * @returns {ReactElement|null}
  548. */
  549. _renderErrorMessage() {
  550. if (!this.state.addToCallError) {
  551. return null;
  552. }
  553. const { t } = this.props;
  554. const supportString = t('inlineDialogFailure.supportMsg');
  555. const supportLink = interfaceConfig.SUPPORT_URL;
  556. const supportLinkContent
  557. = ( // eslint-disable-line no-extra-parens
  558. <span>
  559. <span>
  560. { supportString.padEnd(supportString.length + 1) }
  561. </span>
  562. <span>
  563. <a
  564. href = { supportLink }
  565. rel = 'noopener noreferrer'
  566. target = '_blank'>
  567. { t('inlineDialogFailure.support') }
  568. </a>
  569. </span>
  570. <span>.</span>
  571. </span>
  572. );
  573. return (
  574. <div className = 'modal-dialog-form-error'>
  575. <InlineMessage
  576. title = { t('addPeople.failedToAdd') }
  577. type = 'error'>
  578. { supportLinkContent }
  579. </InlineMessage>
  580. </div>
  581. );
  582. }
  583. /**
  584. * Renders a telephone icon.
  585. *
  586. * @private
  587. * @returns {ReactElement}
  588. */
  589. _renderTelephoneIcon() {
  590. return (
  591. <span className = 'add-telephone-icon'>
  592. <i className = 'icon-telephone' />
  593. </span>
  594. );
  595. }
  596. _setMultiSelectElement: (React$ElementRef<*> | null) => mixed;
  597. /**
  598. * Sets the instance variable for the multi select component
  599. * element so it can be accessed directly.
  600. *
  601. * @param {Object} element - The DOM element for the component's dialog.
  602. * @private
  603. * @returns {void}
  604. */
  605. _setMultiSelectElement(element) {
  606. this._multiselect = element;
  607. }
  608. }
  609. /**
  610. * Maps (parts of) the Redux state to the associated
  611. * {@code AddPeopleDialog}'s props.
  612. *
  613. * @param {Object} state - The Redux state.
  614. * @private
  615. * @returns {{
  616. * _conference: Object,
  617. * _dialOutAuthUrl: string,
  618. * _inviteServiceUrl: string,
  619. * _inviteUrl: string,
  620. * _jwt: string,
  621. * _peopleSearchQueryTypes: Array<string>,
  622. * _peopleSearchUrl: string
  623. * }}
  624. */
  625. function _mapStateToProps(state) {
  626. const { conference } = state['features/base/conference'];
  627. const {
  628. dialOutAuthUrl,
  629. inviteServiceUrl,
  630. peopleSearchQueryTypes,
  631. peopleSearchUrl
  632. } = state['features/base/config'];
  633. return {
  634. _conference: conference,
  635. _dialOutAuthUrl: dialOutAuthUrl,
  636. _inviteServiceUrl: inviteServiceUrl,
  637. _inviteUrl: getInviteURL(state),
  638. _jwt: state['features/base/jwt'].jwt,
  639. _peopleSearchQueryTypes: peopleSearchQueryTypes,
  640. _peopleSearchUrl: peopleSearchUrl
  641. };
  642. }
  643. export default translate(connect(_mapStateToProps, {
  644. hideDialog,
  645. inviteVideoRooms })(
  646. AddPeopleDialog));