Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

AddPeopleDialog.web.js 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. // @flow
  2. import Avatar from '@atlaskit/avatar';
  3. import InlineMessage from '@atlaskit/inline-message';
  4. import { Immutable } from 'nuclear-js';
  5. import PropTypes from 'prop-types';
  6. import React, { Component } from 'react';
  7. import { connect } from 'react-redux';
  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. // FIXME: Remove usage of Immutable. {@code MultiSelectAutocomplete}
  95. // will default to having its internal implementation use a plain array
  96. // if no {@link defaultValue} is passed in. As such is the case, this
  97. // instance of Immutable.List gets overridden with an array on the first
  98. // search.
  99. /**
  100. * The list of invite items.
  101. */
  102. inviteItems: new Immutable.List()
  103. };
  104. /**
  105. * Initializes a new {@code AddPeopleDialog} instance.
  106. *
  107. * @param {Object} props - The read-only properties with which the new
  108. * instance is to be initialized.
  109. */
  110. constructor(props) {
  111. super(props);
  112. // Bind event handlers so they are only bound once per instance.
  113. this._isAddDisabled = this._isAddDisabled.bind(this);
  114. this._onItemSelected = this._onItemSelected.bind(this);
  115. this._onSelectionChange = this._onSelectionChange.bind(this);
  116. this._onSubmit = this._onSubmit.bind(this);
  117. this._parseQueryResults = this._parseQueryResults.bind(this);
  118. this._query = this._query.bind(this);
  119. this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
  120. this._resourceClient = {
  121. makeQuery: this._query,
  122. parseResults: this._parseQueryResults
  123. };
  124. }
  125. /**
  126. * React Component method that executes once component is updated.
  127. *
  128. * @param {Object} prevState - The state object before the update.
  129. * @returns {void}
  130. */
  131. componentDidUpdate(prevState) {
  132. /**
  133. * Clears selected items from the multi select component on successful
  134. * invite.
  135. */
  136. if (prevState.addToCallError
  137. && !this.state.addToCallInProgress
  138. && !this.state.addToCallError
  139. && this._multiselect) {
  140. this._multiselect.setSelectedItems([]);
  141. }
  142. }
  143. /**
  144. * Renders the content of this component.
  145. *
  146. * @returns {ReactElement}
  147. */
  148. render() {
  149. const { enableAddPeople, enableDialOut, t } = this.props;
  150. let isMultiSelectDisabled = this.state.addToCallInProgress || false;
  151. let placeholder;
  152. let loadingMessage;
  153. let noMatches;
  154. if (enableAddPeople && enableDialOut) {
  155. loadingMessage = 'addPeople.loading';
  156. noMatches = 'addPeople.noResults';
  157. placeholder = 'addPeople.searchPeopleAndNumbers';
  158. } else if (enableAddPeople) {
  159. loadingMessage = 'addPeople.loadingPeople';
  160. noMatches = 'addPeople.noResults';
  161. placeholder = 'addPeople.searchPeople';
  162. } else if (enableDialOut) {
  163. loadingMessage = 'addPeople.loadingNumber';
  164. noMatches = 'addPeople.noValidNumbers';
  165. placeholder = 'addPeople.searchNumbers';
  166. } else {
  167. isMultiSelectDisabled = true;
  168. noMatches = 'addPeople.noResults';
  169. placeholder = 'addPeople.disabled';
  170. }
  171. return (
  172. <Dialog
  173. okDisabled = { this._isAddDisabled() }
  174. okTitleKey = 'addPeople.add'
  175. onSubmit = { this._onSubmit }
  176. titleKey = 'addPeople.title'
  177. width = 'medium'>
  178. <div className = 'add-people-form-wrap'>
  179. { this._renderErrorMessage() }
  180. <MultiSelectAutocomplete
  181. isDisabled = { isMultiSelectDisabled }
  182. loadingMessage = { t(loadingMessage) }
  183. noMatchesFound = { t(noMatches) }
  184. onItemSelected = { this._onItemSelected }
  185. onSelectionChange = { this._onSelectionChange }
  186. placeholder = { t(placeholder) }
  187. ref = { this._setMultiSelectElement }
  188. resourceClient = { this._resourceClient }
  189. shouldFitContainer = { true }
  190. shouldFocus = { true } />
  191. </div>
  192. </Dialog>
  193. );
  194. }
  195. _getDigitsOnly: (string) => string;
  196. /**
  197. * Removes all non-numeric characters from a string.
  198. *
  199. * @param {string} text - The string from which to remove all characters
  200. * except numbers.
  201. * @private
  202. * @returns {string} A string with only numbers.
  203. */
  204. _getDigitsOnly(text = '') {
  205. return text.replace(/\D/g, '');
  206. }
  207. _isAddDisabled: () => boolean;
  208. /**
  209. * Indicates if the Add button should be disabled.
  210. *
  211. * @private
  212. * @returns {boolean} - True to indicate that the Add button should
  213. * be disabled, false otherwise.
  214. */
  215. _isAddDisabled() {
  216. return !this.state.inviteItems.length
  217. || this.state.addToCallInProgress;
  218. }
  219. _isMaybeAPhoneNumber: (string) => boolean;
  220. /**
  221. * Checks whether a string looks like it could be for a phone number.
  222. *
  223. * @param {string} text - The text to check whether or not it could be a
  224. * phone number.
  225. * @private
  226. * @returns {boolean} True if the string looks like it could be a phone
  227. * number.
  228. */
  229. _isMaybeAPhoneNumber(text) {
  230. if (!isPhoneNumberRegex.test(text)) {
  231. return false;
  232. }
  233. const digits = this._getDigitsOnly(text);
  234. return Boolean(digits.length);
  235. }
  236. _onItemSelected: (Object) => Object;
  237. /**
  238. * Callback invoked when a selection has been made but before it has been
  239. * set as selected.
  240. *
  241. * @param {Object} item - The item that has just been selected.
  242. * @private
  243. * @returns {Object} The item to display as selected in the input.
  244. */
  245. _onItemSelected(item) {
  246. if (item.item.type === 'phone') {
  247. item.content = item.item.number;
  248. }
  249. return item;
  250. }
  251. _onSelectionChange: (Map<*, *>) => void;
  252. /**
  253. * Handles a selection change.
  254. *
  255. * @param {Map} selectedItems - The list of selected items.
  256. * @private
  257. * @returns {void}
  258. */
  259. _onSelectionChange(selectedItems) {
  260. this.setState({
  261. inviteItems: selectedItems
  262. });
  263. }
  264. _onSubmit: () => void;
  265. /**
  266. * Invite people and numbers to the conference. The logic works by inviting
  267. * numbers, people/rooms, and videosipgw in parallel. All invitees are
  268. * stored in an array. As each invite succeeds, the invitee is removed
  269. * from the array. After all invites finish, close the modal if there are
  270. * no invites left to send. If any are left, that means an invite failed
  271. * and an error state should display.
  272. *
  273. * @private
  274. * @returns {void}
  275. */
  276. _onSubmit() {
  277. if (this._isAddDisabled()) {
  278. return;
  279. }
  280. this.setState({
  281. addToCallInProgress: true
  282. });
  283. let allInvitePromises = [];
  284. let invitesLeftToSend = [
  285. ...this.state.inviteItems
  286. ];
  287. // First create all promises for dialing out.
  288. if (this.props.enableDialOut && this.props._conference) {
  289. const phoneNumbers = invitesLeftToSend.filter(
  290. ({ item }) => item.type === 'phone');
  291. // For each number, dial out. On success, remove the number from
  292. // {@link invitesLeftToSend}.
  293. const phoneInvitePromises = phoneNumbers.map(number => {
  294. const numberToInvite = this._getDigitsOnly(number.item.number);
  295. return this.props._conference.dial(numberToInvite)
  296. .then(() => {
  297. invitesLeftToSend
  298. = invitesLeftToSend.filter(invite =>
  299. invite !== number);
  300. })
  301. .catch(error => logger.error(
  302. 'Error inviting phone number:', error));
  303. });
  304. allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
  305. }
  306. if (this.props.enableAddPeople) {
  307. const usersAndRooms = invitesLeftToSend.filter(i =>
  308. i.item.type === 'user' || i.item.type === 'room')
  309. .map(i => i.item);
  310. if (usersAndRooms.length) {
  311. // Send a request to invite all the rooms and users. On success,
  312. // filter all rooms and users from {@link invitesLeftToSend}.
  313. const peopleInvitePromise = invitePeopleAndChatRooms(
  314. this.props._inviteServiceUrl,
  315. this.props._inviteUrl,
  316. this.props._jwt,
  317. usersAndRooms)
  318. .then(() => {
  319. invitesLeftToSend = invitesLeftToSend.filter(i =>
  320. i.item.type !== 'user' && i.item.type !== 'room');
  321. })
  322. .catch(error => logger.error(
  323. 'Error inviting people:', error));
  324. allInvitePromises.push(peopleInvitePromise);
  325. }
  326. // Sipgw calls are fire and forget. Invite them to the conference
  327. // then immediately remove them from {@link invitesLeftToSend}.
  328. const vrooms = invitesLeftToSend.filter(i =>
  329. i.item.type === 'videosipgw')
  330. .map(i => i.item);
  331. this.props._conference
  332. && vrooms.length > 0
  333. && this.props.inviteVideoRooms(
  334. this.props._conference, vrooms);
  335. invitesLeftToSend = invitesLeftToSend.filter(i =>
  336. i.item.type !== 'videosipgw');
  337. }
  338. Promise.all(allInvitePromises)
  339. .then(() => {
  340. // If any invites are left that means something failed to send
  341. // so treat it as an error.
  342. if (invitesLeftToSend.length) {
  343. logger.error(`${invitesLeftToSend.length} invites failed`);
  344. this.setState({
  345. addToCallInProgress: false,
  346. addToCallError: true
  347. });
  348. if (this._multiselect) {
  349. this._multiselect.setSelectedItems(invitesLeftToSend);
  350. }
  351. return;
  352. }
  353. this.setState({
  354. addToCallInProgress: false
  355. });
  356. this.props.hideDialog();
  357. });
  358. }
  359. _parseQueryResults: (Array<Object>, string) => Array<Object>;
  360. /**
  361. * Processes results from requesting available numbers and people by munging
  362. * each result into a format {@code MultiSelectAutocomplete} can use for
  363. * display.
  364. *
  365. * @param {Array} response - The response object from the server for the
  366. * query.
  367. * @private
  368. * @returns {Object[]} Configuration objects for items to display in the
  369. * search autocomplete.
  370. */
  371. _parseQueryResults(response = []) {
  372. const { t } = this.props;
  373. const users = response.filter(item => item.type !== 'phone');
  374. const userDisplayItems = users.map(user => {
  375. return {
  376. content: user.name,
  377. elemBefore: <Avatar
  378. size = 'medium'
  379. src = { user.avatar } />,
  380. item: user,
  381. tag: {
  382. elemBefore: <Avatar
  383. size = 'xsmall'
  384. src = { user.avatar } />
  385. },
  386. value: user.id
  387. };
  388. });
  389. const numbers = response.filter(item => item.type === 'phone');
  390. const telephoneIcon = this._renderTelephoneIcon();
  391. const numberDisplayItems = numbers.map(number => {
  392. const numberNotAllowedMessage
  393. = number.allowed ? '' : t('addPeople.countryNotSupported');
  394. const countryCodeReminder = number.showCountryCodeReminder
  395. ? t('addPeople.countryReminder') : '';
  396. const description
  397. = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
  398. return {
  399. filterValues: [
  400. number.originalEntry,
  401. number.number
  402. ],
  403. content: t('addPeople.telephone', { number: number.number }),
  404. description,
  405. isDisabled: !number.allowed,
  406. elemBefore: telephoneIcon,
  407. item: number,
  408. tag: {
  409. elemBefore: telephoneIcon
  410. },
  411. value: number.number
  412. };
  413. });
  414. return [
  415. ...userDisplayItems,
  416. ...numberDisplayItems
  417. ];
  418. }
  419. _query: (string) => Promise<Array<Object>>;
  420. /**
  421. * Performs a people and phone number search request.
  422. *
  423. * @param {string} query - The search text.
  424. * @private
  425. * @returns {Promise}
  426. */
  427. _query(query = '') {
  428. const text = query.trim();
  429. const {
  430. _dialOutAuthUrl,
  431. _jwt,
  432. _peopleSearchQueryTypes,
  433. _peopleSearchUrl
  434. } = this.props;
  435. let peopleSearchPromise;
  436. if (this.props.enableAddPeople) {
  437. peopleSearchPromise = searchDirectory(
  438. _peopleSearchUrl,
  439. _jwt,
  440. text,
  441. _peopleSearchQueryTypes);
  442. } else {
  443. peopleSearchPromise = Promise.resolve([]);
  444. }
  445. const hasCountryCode = text.startsWith('+');
  446. let phoneNumberPromise;
  447. if (this.props.enableDialOut && this._isMaybeAPhoneNumber(text)) {
  448. let numberToVerify = text;
  449. // When the number to verify does not start with a +, we assume no
  450. // proper country code has been entered. In such a case, prepend 1
  451. // for the country code. The service currently takes care of
  452. // prepending the +.
  453. if (!hasCountryCode && !text.startsWith('1')) {
  454. numberToVerify = `1${numberToVerify}`;
  455. }
  456. // The validation service works properly when the query is digits
  457. // only so ensure only digits get sent.
  458. numberToVerify = this._getDigitsOnly(numberToVerify);
  459. phoneNumberPromise
  460. = checkDialNumber(numberToVerify, _dialOutAuthUrl);
  461. } else {
  462. phoneNumberPromise = Promise.resolve({});
  463. }
  464. return Promise.all([ peopleSearchPromise, phoneNumberPromise ])
  465. .then(([ peopleResults, phoneResults ]) => {
  466. const results = [
  467. ...peopleResults
  468. ];
  469. /**
  470. * This check for phone results is for the day the call to
  471. * searching people might return phone results as well. When
  472. * that day comes this check will make it so the server checks
  473. * are honored and the local appending of the number is not
  474. * done. The local appending of the phone number can then be
  475. * cleaned up when convenient.
  476. */
  477. const hasPhoneResult = peopleResults.find(
  478. result => result.type === 'phone');
  479. if (!hasPhoneResult
  480. && typeof phoneResults.allow === 'boolean') {
  481. results.push({
  482. allowed: phoneResults.allow,
  483. country: phoneResults.country,
  484. type: 'phone',
  485. number: phoneResults.phone,
  486. originalEntry: text,
  487. showCountryCodeReminder: !hasCountryCode
  488. });
  489. }
  490. return results;
  491. });
  492. }
  493. /**
  494. * Renders the error message if the add doesn't succeed.
  495. *
  496. * @private
  497. * @returns {ReactElement|null}
  498. */
  499. _renderErrorMessage() {
  500. if (!this.state.addToCallError) {
  501. return null;
  502. }
  503. const { t } = this.props;
  504. const supportString = t('inlineDialogFailure.supportMsg');
  505. const supportLink = interfaceConfig.SUPPORT_URL;
  506. const supportLinkContent
  507. = ( // eslint-disable-line no-extra-parens
  508. <span>
  509. <span>
  510. { supportString.padEnd(supportString.length + 1) }
  511. </span>
  512. <span>
  513. <a
  514. href = { supportLink }
  515. rel = 'noopener noreferrer'
  516. target = '_blank'>
  517. { t('inlineDialogFailure.support') }
  518. </a>
  519. </span>
  520. <span>.</span>
  521. </span>
  522. );
  523. return (
  524. <div className = 'modal-dialog-form-error'>
  525. <InlineMessage
  526. title = { t('addPeople.failedToAdd') }
  527. type = 'error'>
  528. { supportLinkContent }
  529. </InlineMessage>
  530. </div>
  531. );
  532. }
  533. /**
  534. * Renders a telephone icon.
  535. *
  536. * @private
  537. * @returns {ReactElement}
  538. */
  539. _renderTelephoneIcon() {
  540. return (
  541. <span className = 'add-telephone-icon'>
  542. <i className = 'icon-telephone' />
  543. </span>
  544. );
  545. }
  546. _setMultiSelectElement: (React$ElementRef<*> | null) => mixed;
  547. /**
  548. * Sets the instance variable for the multi select component
  549. * element so it can be accessed directly.
  550. *
  551. * @param {Object} element - The DOM element for the component's dialog.
  552. * @private
  553. * @returns {void}
  554. */
  555. _setMultiSelectElement(element) {
  556. this._multiselect = element;
  557. }
  558. }
  559. /**
  560. * Maps (parts of) the Redux state to the associated
  561. * {@code AddPeopleDialog}'s props.
  562. *
  563. * @param {Object} state - The Redux state.
  564. * @private
  565. * @returns {{
  566. * _conference: Object,
  567. * _dialOutAuthUrl: string,
  568. * _inviteServiceUrl: string,
  569. * _inviteUrl: string,
  570. * _jwt: string,
  571. * _peopleSearchQueryTypes: Array<string>,
  572. * _peopleSearchUrl: string
  573. * }}
  574. */
  575. function _mapStateToProps(state) {
  576. const { conference } = state['features/base/conference'];
  577. const {
  578. dialOutAuthUrl,
  579. inviteServiceUrl,
  580. peopleSearchQueryTypes,
  581. peopleSearchUrl
  582. } = state['features/base/config'];
  583. return {
  584. _conference: conference,
  585. _dialOutAuthUrl: dialOutAuthUrl,
  586. _inviteServiceUrl: inviteServiceUrl,
  587. _inviteUrl: getInviteURL(state),
  588. _jwt: state['features/base/jwt'].jwt,
  589. _peopleSearchQueryTypes: peopleSearchQueryTypes,
  590. _peopleSearchUrl: peopleSearchUrl
  591. };
  592. }
  593. export default translate(connect(_mapStateToProps, {
  594. hideDialog,
  595. inviteVideoRooms })(
  596. AddPeopleDialog));