您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

AddPeopleDialog.web.js 21KB

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