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 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. // @flow
  2. import Avatar from '@atlaskit/avatar';
  3. import InlineMessage from '@atlaskit/inline-message';
  4. import React, { Component } from 'react';
  5. import { connect } from 'react-redux';
  6. import { createInviteDialogEvent, sendAnalytics } from '../../analytics';
  7. import { Dialog, hideDialog } from '../../base/dialog';
  8. import { translate, translateToHTML } from '../../base/i18n';
  9. import { getLocalParticipant } from '../../base/participants';
  10. import { MultiSelectAutocomplete } from '../../base/react';
  11. import { invite } from '../actions';
  12. import { getInviteResultsForQuery, getInviteTypeCounts } from '../functions';
  13. const logger = require('jitsi-meet-logger').getLogger(__filename);
  14. declare var interfaceConfig: Object;
  15. /**
  16. * The type of the React {@code Component} props of {@link AddPeopleDialog}.
  17. */
  18. type Props = {
  19. /**
  20. * The {@link JitsiMeetConference} which will be used to invite "room"
  21. * participants through the SIP Jibri (Video SIP gateway).
  22. */
  23. _conference: Object,
  24. /**
  25. * The URL for validating if a phone number can be called.
  26. */
  27. _dialOutAuthUrl: string,
  28. /**
  29. * Whether to show a footer text after the search results as a last element.
  30. */
  31. _footerTextEnabled: boolean,
  32. /**
  33. * The JWT token.
  34. */
  35. _jwt: string,
  36. /**
  37. * The query types used when searching people.
  38. */
  39. _peopleSearchQueryTypes: Array<string>,
  40. /**
  41. * The URL pointing to the service allowing for people search.
  42. */
  43. _peopleSearchUrl: string,
  44. /**
  45. * Whether or not to show Add People functionality.
  46. */
  47. addPeopleEnabled: boolean,
  48. /**
  49. * Whether or not to show Dial Out functionality.
  50. */
  51. dialOutEnabled: boolean,
  52. /**
  53. * The redux {@code dispatch} function.
  54. */
  55. dispatch: Dispatch<*>,
  56. /**
  57. * Invoked to obtain translated strings.
  58. */
  59. t: Function,
  60. };
  61. /**
  62. * The type of the React {@code Component} state of {@link AddPeopleDialog}.
  63. */
  64. type State = {
  65. /**
  66. * Indicating that an error occurred when adding people to the call.
  67. */
  68. addToCallError: boolean,
  69. /**
  70. * Indicating that we're currently adding the new people to the
  71. * call.
  72. */
  73. addToCallInProgress: boolean,
  74. /**
  75. * The list of invite items.
  76. */
  77. inviteItems: Array<Object>
  78. };
  79. /**
  80. * The dialog that allows to invite people to the call.
  81. */
  82. class AddPeopleDialog extends Component<Props, State> {
  83. _multiselect = null;
  84. _resourceClient: Object;
  85. state = {
  86. addToCallError: false,
  87. addToCallInProgress: false,
  88. inviteItems: []
  89. };
  90. /**
  91. * Initializes a new {@code AddPeopleDialog} instance.
  92. *
  93. * @param {Object} props - The read-only properties with which the new
  94. * instance is to be initialized.
  95. */
  96. constructor(props: Props) {
  97. super(props);
  98. // Bind event handlers so they are only bound once per instance.
  99. this._isAddDisabled = this._isAddDisabled.bind(this);
  100. this._onItemSelected = this._onItemSelected.bind(this);
  101. this._onSelectionChange = this._onSelectionChange.bind(this);
  102. this._onSubmit = this._onSubmit.bind(this);
  103. this._parseQueryResults = this._parseQueryResults.bind(this);
  104. this._query = this._query.bind(this);
  105. this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
  106. this._resourceClient = {
  107. makeQuery: this._query,
  108. parseResults: this._parseQueryResults
  109. };
  110. }
  111. /**
  112. * Sends an analytics event to record the dialog has been shown.
  113. *
  114. * @inheritdoc
  115. * @returns {void}
  116. */
  117. componentDidMount() {
  118. sendAnalytics(createInviteDialogEvent(
  119. 'invite.dialog.opened', 'dialog'));
  120. }
  121. /**
  122. * React Component method that executes once component is updated.
  123. *
  124. * @param {Object} prevProps - The state object before the update.
  125. * @param {Object} prevState - The state object before the update.
  126. * @returns {void}
  127. */
  128. componentDidUpdate(prevProps, prevState) {
  129. /**
  130. * Clears selected items from the multi select component on successful
  131. * invite.
  132. */
  133. if (prevState.addToCallError
  134. && !this.state.addToCallInProgress
  135. && !this.state.addToCallError
  136. && this._multiselect) {
  137. this._multiselect.setSelectedItems([]);
  138. }
  139. }
  140. /**
  141. * Sends an analytics event to record the dialog has been closed.
  142. *
  143. * @inheritdoc
  144. * @returns {void}
  145. */
  146. componentWillUnmount() {
  147. sendAnalytics(createInviteDialogEvent(
  148. 'invite.dialog.closed', 'dialog'));
  149. }
  150. /**
  151. * Renders the content of this component.
  152. *
  153. * @returns {ReactElement}
  154. */
  155. render() {
  156. const { _footerTextEnabled,
  157. addPeopleEnabled,
  158. dialOutEnabled,
  159. t } = this.props;
  160. let isMultiSelectDisabled = this.state.addToCallInProgress || false;
  161. let placeholder;
  162. let loadingMessage;
  163. let noMatches;
  164. let footerText;
  165. if (addPeopleEnabled && dialOutEnabled) {
  166. loadingMessage = 'addPeople.loading';
  167. noMatches = 'addPeople.noResults';
  168. placeholder = 'addPeople.searchPeopleAndNumbers';
  169. } else if (addPeopleEnabled) {
  170. loadingMessage = 'addPeople.loadingPeople';
  171. noMatches = 'addPeople.noResults';
  172. placeholder = 'addPeople.searchPeople';
  173. } else if (dialOutEnabled) {
  174. loadingMessage = 'addPeople.loadingNumber';
  175. noMatches = 'addPeople.noValidNumbers';
  176. placeholder = 'addPeople.searchNumbers';
  177. } else {
  178. isMultiSelectDisabled = true;
  179. noMatches = 'addPeople.noResults';
  180. placeholder = 'addPeople.disabled';
  181. }
  182. if (_footerTextEnabled) {
  183. footerText = {
  184. content: <div className = 'footer-text-wrap'>
  185. <div>
  186. <span className = 'footer-telephone-icon'>
  187. <i className = 'icon-telephone' />
  188. </span>
  189. </div>
  190. { translateToHTML(t, 'addPeople.footerText') }
  191. </div>
  192. };
  193. }
  194. return (
  195. <Dialog
  196. okDisabled = { this._isAddDisabled() }
  197. okTitleKey = 'addPeople.add'
  198. onSubmit = { this._onSubmit }
  199. titleKey = 'addPeople.title'
  200. width = 'medium'>
  201. <div className = 'add-people-form-wrap'>
  202. { this._renderErrorMessage() }
  203. <MultiSelectAutocomplete
  204. footer = { footerText }
  205. isDisabled = { isMultiSelectDisabled }
  206. loadingMessage = { t(loadingMessage) }
  207. noMatchesFound = { t(noMatches) }
  208. onItemSelected = { this._onItemSelected }
  209. onSelectionChange = { this._onSelectionChange }
  210. placeholder = { t(placeholder) }
  211. ref = { this._setMultiSelectElement }
  212. resourceClient = { this._resourceClient }
  213. shouldFitContainer = { true }
  214. shouldFocus = { true } />
  215. </div>
  216. </Dialog>
  217. );
  218. }
  219. _isAddDisabled: () => boolean;
  220. /**
  221. * Indicates if the Add button should be disabled.
  222. *
  223. * @private
  224. * @returns {boolean} - True to indicate that the Add button should
  225. * be disabled, false otherwise.
  226. */
  227. _isAddDisabled() {
  228. return !this.state.inviteItems.length
  229. || this.state.addToCallInProgress;
  230. }
  231. _onItemSelected: (Object) => Object;
  232. /**
  233. * Callback invoked when a selection has been made but before it has been
  234. * set as selected.
  235. *
  236. * @param {Object} item - The item that has just been selected.
  237. * @private
  238. * @returns {Object} The item to display as selected in the input.
  239. */
  240. _onItemSelected(item) {
  241. if (item.item.type === 'phone') {
  242. item.content = item.item.number;
  243. }
  244. return item;
  245. }
  246. _onSelectionChange: (Map<*, *>) => void;
  247. /**
  248. * Handles a selection change.
  249. *
  250. * @param {Map} selectedItems - The list of selected items.
  251. * @private
  252. * @returns {void}
  253. */
  254. _onSelectionChange(selectedItems) {
  255. this.setState({
  256. inviteItems: selectedItems
  257. });
  258. }
  259. _onSubmit: () => void;
  260. /**
  261. * Invite people and numbers to the conference. The logic works by inviting
  262. * numbers, people/rooms, and videosipgw in parallel. All invitees are
  263. * stored in an array. As each invite succeeds, the invitee is removed
  264. * from the array. After all invites finish, close the modal if there are
  265. * no invites left to send. If any are left, that means an invite failed
  266. * and an error state should display.
  267. *
  268. * @private
  269. * @returns {void}
  270. */
  271. _onSubmit() {
  272. const { inviteItems } = this.state;
  273. const invitees = inviteItems.map(({ item }) => item);
  274. const inviteTypeCounts = getInviteTypeCounts(invitees);
  275. sendAnalytics(createInviteDialogEvent(
  276. 'clicked', 'inviteButton', {
  277. ...inviteTypeCounts,
  278. inviteAllowed: this._isAddDisabled()
  279. }));
  280. if (this._isAddDisabled()) {
  281. return;
  282. }
  283. this.setState({
  284. addToCallInProgress: true
  285. });
  286. const { dispatch } = this.props;
  287. dispatch(invite(invitees))
  288. .then(invitesLeftToSend => {
  289. // If any invites are left that means something failed to send
  290. // so treat it as an error.
  291. if (invitesLeftToSend.length) {
  292. const erroredInviteTypeCounts
  293. = getInviteTypeCounts(invitesLeftToSend);
  294. logger.error(`${invitesLeftToSend.length} invites failed`,
  295. erroredInviteTypeCounts);
  296. sendAnalytics(createInviteDialogEvent(
  297. 'error', 'invite', {
  298. ...erroredInviteTypeCounts
  299. }));
  300. this.setState({
  301. addToCallInProgress: false,
  302. addToCallError: true
  303. });
  304. const unsentInviteIDs
  305. = invitesLeftToSend.map(invitee =>
  306. invitee.id || invitee.number);
  307. const itemsToSelect
  308. = inviteItems.filter(({ item }) =>
  309. unsentInviteIDs.includes(item.id || item.number));
  310. if (this._multiselect) {
  311. this._multiselect.setSelectedItems(itemsToSelect);
  312. }
  313. return;
  314. }
  315. this.setState({
  316. addToCallInProgress: false
  317. });
  318. dispatch(hideDialog());
  319. });
  320. }
  321. _parseQueryResults: (Array<Object>, string) => Array<Object>;
  322. /**
  323. * Processes results from requesting available numbers and people by munging
  324. * each result into a format {@code MultiSelectAutocomplete} can use for
  325. * display.
  326. *
  327. * @param {Array} response - The response object from the server for the
  328. * query.
  329. * @private
  330. * @returns {Object[]} Configuration objects for items to display in the
  331. * search autocomplete.
  332. */
  333. _parseQueryResults(response = []) {
  334. const { t } = this.props;
  335. const users = response.filter(item => item.type !== 'phone');
  336. const userDisplayItems = users.map(user => {
  337. return {
  338. content: user.name,
  339. elemBefore: <Avatar
  340. size = 'small'
  341. src = { user.avatar } />,
  342. item: user,
  343. tag: {
  344. elemBefore: <Avatar
  345. size = 'xsmall'
  346. src = { user.avatar } />
  347. },
  348. value: user.id
  349. };
  350. });
  351. const numbers = response.filter(item => item.type === 'phone');
  352. const telephoneIcon = this._renderTelephoneIcon();
  353. const numberDisplayItems = numbers.map(number => {
  354. const numberNotAllowedMessage
  355. = number.allowed ? '' : t('addPeople.countryNotSupported');
  356. const countryCodeReminder = number.showCountryCodeReminder
  357. ? t('addPeople.countryReminder') : '';
  358. const description
  359. = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
  360. return {
  361. filterValues: [
  362. number.originalEntry,
  363. number.number
  364. ],
  365. content: t('addPeople.telephone', { number: number.number }),
  366. description,
  367. isDisabled: !number.allowed,
  368. elemBefore: telephoneIcon,
  369. item: number,
  370. tag: {
  371. elemBefore: telephoneIcon
  372. },
  373. value: number.number
  374. };
  375. });
  376. return [
  377. ...userDisplayItems,
  378. ...numberDisplayItems
  379. ];
  380. }
  381. _query: (string) => Promise<Array<Object>>;
  382. /**
  383. * Performs a people and phone number search request.
  384. *
  385. * @param {string} query - The search text.
  386. * @private
  387. * @returns {Promise}
  388. */
  389. _query(query = '') {
  390. const {
  391. addPeopleEnabled,
  392. dialOutEnabled,
  393. _dialOutAuthUrl,
  394. _jwt,
  395. _peopleSearchQueryTypes,
  396. _peopleSearchUrl
  397. } = this.props;
  398. const options = {
  399. dialOutAuthUrl: _dialOutAuthUrl,
  400. addPeopleEnabled,
  401. dialOutEnabled,
  402. jwt: _jwt,
  403. peopleSearchQueryTypes: _peopleSearchQueryTypes,
  404. peopleSearchUrl: _peopleSearchUrl
  405. };
  406. return getInviteResultsForQuery(query, options);
  407. }
  408. /**
  409. * Renders the error message if the add doesn't succeed.
  410. *
  411. * @private
  412. * @returns {ReactElement|null}
  413. */
  414. _renderErrorMessage() {
  415. if (!this.state.addToCallError) {
  416. return null;
  417. }
  418. const { t } = this.props;
  419. const supportString = t('inlineDialogFailure.supportMsg');
  420. const supportLink = interfaceConfig.SUPPORT_URL;
  421. const supportLinkContent
  422. = (
  423. <span>
  424. <span>
  425. { supportString.padEnd(supportString.length + 1) }
  426. </span>
  427. <span>
  428. <a
  429. href = { supportLink }
  430. rel = 'noopener noreferrer'
  431. target = '_blank'>
  432. { t('inlineDialogFailure.support') }
  433. </a>
  434. </span>
  435. <span>.</span>
  436. </span>
  437. );
  438. return (
  439. <div className = 'modal-dialog-form-error'>
  440. <InlineMessage
  441. title = { t('addPeople.failedToAdd') }
  442. type = 'error'>
  443. { supportLinkContent }
  444. </InlineMessage>
  445. </div>
  446. );
  447. }
  448. /**
  449. * Renders a telephone icon.
  450. *
  451. * @private
  452. * @returns {ReactElement}
  453. */
  454. _renderTelephoneIcon() {
  455. return (
  456. <span className = 'add-telephone-icon'>
  457. <i className = 'icon-telephone' />
  458. </span>
  459. );
  460. }
  461. _setMultiSelectElement: (React$ElementRef<*> | null) => mixed;
  462. /**
  463. * Sets the instance variable for the multi select component
  464. * element so it can be accessed directly.
  465. *
  466. * @param {Object} element - The DOM element for the component's dialog.
  467. * @private
  468. * @returns {void}
  469. */
  470. _setMultiSelectElement(element) {
  471. this._multiselect = element;
  472. }
  473. }
  474. /**
  475. * Maps (parts of) the Redux state to the associated
  476. * {@code AddPeopleDialog}'s props.
  477. *
  478. * @param {Object} state - The Redux state.
  479. * @private
  480. * @returns {{
  481. * _dialOutAuthUrl: string,
  482. * _jwt: string,
  483. * _peopleSearchQueryTypes: Array<string>,
  484. * _peopleSearchUrl: string
  485. * }}
  486. */
  487. function _mapStateToProps(state) {
  488. const {
  489. dialOutAuthUrl,
  490. enableFeaturesBasedOnToken,
  491. peopleSearchQueryTypes,
  492. peopleSearchUrl
  493. } = state['features/base/config'];
  494. let footerTextEnabled = false;
  495. if (enableFeaturesBasedOnToken) {
  496. const { features = {} } = getLocalParticipant(state);
  497. if (String(features['outbound-call']) !== 'true') {
  498. footerTextEnabled = true;
  499. }
  500. }
  501. return {
  502. _dialOutAuthUrl: dialOutAuthUrl,
  503. _footerTextEnabled: footerTextEnabled,
  504. _jwt: state['features/base/jwt'].jwt,
  505. _peopleSearchQueryTypes: peopleSearchQueryTypes,
  506. _peopleSearchUrl: peopleSearchUrl
  507. };
  508. }
  509. export default translate(connect(_mapStateToProps)(AddPeopleDialog));