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.

InviteContactsForm.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import { Theme } from '@mui/material';
  2. import { withStyles } from '@mui/styles';
  3. import React from 'react';
  4. import { connect } from 'react-redux';
  5. import { IReduxState, IStore } from '../../../../app/types';
  6. import Avatar from '../../../../base/avatar/components/Avatar';
  7. import { translate } from '../../../../base/i18n/functions';
  8. import Icon from '../../../../base/icons/components/Icon';
  9. import { IconPhoneRinging } from '../../../../base/icons/svg';
  10. import MultiSelectAutocomplete from '../../../../base/react/components/web/MultiSelectAutocomplete';
  11. import Button from '../../../../base/ui/components/web/Button';
  12. import { BUTTON_TYPES } from '../../../../base/ui/constants.any';
  13. import { isVpaasMeeting } from '../../../../jaas/functions';
  14. import { hideAddPeopleDialog } from '../../../actions.web';
  15. import { INVITE_TYPES } from '../../../constants';
  16. import { IInviteSelectItem, IInvitee } from '../../../types';
  17. import AbstractAddPeopleDialog, {
  18. IProps as AbstractProps,
  19. IState,
  20. _mapStateToProps as _abstractMapStateToProps
  21. } from '../AbstractAddPeopleDialog';
  22. const styles = (theme: Theme) => {
  23. return {
  24. formWrap: {
  25. marginTop: theme.spacing(2)
  26. },
  27. inviteButtons: {
  28. display: 'flex',
  29. justifyContent: 'end',
  30. marginTop: theme.spacing(2),
  31. '& .invite-button': {
  32. marginLeft: theme.spacing(2)
  33. }
  34. }
  35. };
  36. };
  37. interface IProps extends AbstractProps {
  38. /**
  39. * The {@link JitsiMeetConference} which will be used to invite "room" participants.
  40. */
  41. _conference?: Object;
  42. /**
  43. * Whether the meeting belongs to JaaS user.
  44. */
  45. _isVpaas?: boolean;
  46. /**
  47. * Css classes.
  48. */
  49. classes: any;
  50. /**
  51. * The redux {@code dispatch} function.
  52. */
  53. dispatch: IStore['dispatch'];
  54. /**
  55. * Invoked to obtain translated strings.
  56. */
  57. t: Function;
  58. }
  59. /**
  60. * Form that enables inviting others to the call.
  61. */
  62. class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
  63. _multiselect: MultiSelectAutocomplete | null = null;
  64. _resourceClient: {
  65. makeQuery: (query: string) => Promise<Array<any>>;
  66. parseResults: Function;
  67. };
  68. _translations: {
  69. [key: string]: string;
  70. _addPeopleEnabled: string;
  71. _dialOutEnabled: string;
  72. _sipInviteEnabled: string;
  73. };
  74. state = {
  75. addToCallError: false,
  76. addToCallInProgress: false,
  77. inviteItems: [] as IInviteSelectItem[]
  78. };
  79. /**
  80. * Initializes a new {@code AddPeopleDialog} instance.
  81. *
  82. * @param {Object} props - The read-only properties with which the new
  83. * instance is to be initialized.
  84. */
  85. constructor(props: IProps) {
  86. super(props);
  87. // Bind event handlers so they are only bound once per instance.
  88. this._onClearItems = this._onClearItems.bind(this);
  89. this._onClearItemsKeyPress = this._onClearItemsKeyPress.bind(this);
  90. this._onItemSelected = this._onItemSelected.bind(this);
  91. this._onSelectionChange = this._onSelectionChange.bind(this);
  92. this._onSubmit = this._onSubmit.bind(this);
  93. this._onSubmitKeyPress = this._onSubmitKeyPress.bind(this);
  94. this._parseQueryResults = this._parseQueryResults.bind(this);
  95. this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
  96. this._onKeyDown = this._onKeyDown.bind(this);
  97. this._resourceClient = {
  98. makeQuery: this._query,
  99. parseResults: this._parseQueryResults
  100. };
  101. const { t } = props;
  102. this._translations = {
  103. _dialOutEnabled: t('addPeople.phoneNumbers'),
  104. _addPeopleEnabled: t('addPeople.contacts'),
  105. _sipInviteEnabled: t('addPeople.sipAddresses')
  106. };
  107. }
  108. /**
  109. * React Component method that executes once component is updated.
  110. *
  111. * @param {Props} prevProps - The props object before the update.
  112. * @param {State} prevState - The state object before the update.
  113. * @returns {void}
  114. */
  115. componentDidUpdate(prevProps: IProps, prevState: IState) {
  116. /**
  117. * Clears selected items from the multi select component on successful
  118. * invite.
  119. */
  120. if (prevState.addToCallError
  121. && !this.state.addToCallInProgress
  122. && !this.state.addToCallError
  123. && this._multiselect) {
  124. this._multiselect.setSelectedItems([]);
  125. }
  126. }
  127. /**
  128. * Renders the content of this component.
  129. *
  130. * @returns {ReactElement}
  131. */
  132. render() {
  133. const {
  134. _addPeopleEnabled,
  135. _dialOutEnabled,
  136. _isVpaas,
  137. _sipInviteEnabled,
  138. t
  139. } = this.props;
  140. let isMultiSelectDisabled = this.state.addToCallInProgress;
  141. const loadingMessage = 'addPeople.searching';
  142. const noMatches = 'addPeople.noResults';
  143. const features: { [key: string]: boolean; } = {
  144. _dialOutEnabled,
  145. _addPeopleEnabled,
  146. _sipInviteEnabled
  147. };
  148. const computedPlaceholder = Object.keys(features)
  149. .filter(v => Boolean(features[v]))
  150. .map(v => this._translations[v])
  151. .join(', ');
  152. const placeholder = computedPlaceholder ? `${t('dialog.add')} ${computedPlaceholder}` : t('addPeople.disabled');
  153. if (!computedPlaceholder) {
  154. isMultiSelectDisabled = true;
  155. }
  156. return (
  157. <div
  158. className = { this.props.classes.formWrap }
  159. onKeyDown = { this._onKeyDown }>
  160. <MultiSelectAutocomplete
  161. isDisabled = { isMultiSelectDisabled }
  162. loadingMessage = { t(loadingMessage) }
  163. noMatchesFound = { t(noMatches) }
  164. onItemSelected = { this._onItemSelected }
  165. onSelectionChange = { this._onSelectionChange }
  166. placeholder = { placeholder }
  167. ref = { this._setMultiSelectElement }
  168. resourceClient = { this._resourceClient }
  169. shouldFitContainer = { true }
  170. shouldFocus = { true }
  171. showSupportLink = { !_isVpaas } />
  172. { this._renderFormActions() }
  173. </div>
  174. );
  175. }
  176. _isAddDisabled: () => boolean;
  177. /**
  178. * Callback invoked when a selection has been made but before it has been
  179. * set as selected.
  180. *
  181. * @param {IInviteSelectItem} item - The item that has just been selected.
  182. * @private
  183. * @returns {Object} The item to display as selected in the input.
  184. */
  185. _onItemSelected(item: IInviteSelectItem) {
  186. if (item.item.type === INVITE_TYPES.PHONE) {
  187. item.content = item.item.number;
  188. }
  189. return item;
  190. }
  191. /**
  192. * Handles a selection change.
  193. *
  194. * @param {Array<IInviteSelectItem>} selectedItems - The list of selected items.
  195. * @private
  196. * @returns {void}
  197. */
  198. _onSelectionChange(selectedItems: IInviteSelectItem[]) {
  199. this.setState({
  200. inviteItems: selectedItems
  201. });
  202. }
  203. /**
  204. * Submits the selection for inviting.
  205. *
  206. * @private
  207. * @returns {void}
  208. */
  209. _onSubmit() {
  210. const { inviteItems } = this.state;
  211. const invitees = inviteItems.map(({ item }) => item);
  212. this._invite(invitees)
  213. .then((invitesLeftToSend: IInvitee[]) => {
  214. if (invitesLeftToSend.length) {
  215. const unsentInviteIDs
  216. = invitesLeftToSend.map(invitee =>
  217. invitee.id || invitee.user_id || invitee.number);
  218. const itemsToSelect = inviteItems.filter(({ item }) =>
  219. unsentInviteIDs.includes(item.id || item.user_id || item.number));
  220. if (this._multiselect) {
  221. this._multiselect.setSelectedItems(itemsToSelect);
  222. }
  223. }
  224. })
  225. .finally(() => this.props.dispatch(hideAddPeopleDialog()));
  226. }
  227. /**
  228. * KeyPress handler for accessibility.
  229. *
  230. * @param {KeyboardEvent} e - The key event to handle.
  231. *
  232. * @returns {void}
  233. */
  234. _onSubmitKeyPress(e: React.KeyboardEvent) {
  235. if (e.key === ' ' || e.key === 'Enter') {
  236. e.preventDefault();
  237. this._onSubmit();
  238. }
  239. }
  240. /**
  241. * Handles 'Enter' key in the form to trigger the invite.
  242. *
  243. * @param {KeyboardEvent} event - The key event.
  244. * @returns {void}
  245. */
  246. _onKeyDown(event: React.KeyboardEvent) {
  247. const { inviteItems } = this.state;
  248. if (event.key === 'Enter') {
  249. event.preventDefault();
  250. if (!this._isAddDisabled() && inviteItems.length) {
  251. this._onSubmit();
  252. }
  253. }
  254. }
  255. /**
  256. * Returns the avatar component for a user.
  257. *
  258. * @param {any} user - The user.
  259. * @param {string} className - The CSS class for the avatar component.
  260. * @private
  261. * @returns {ReactElement}
  262. */
  263. _getAvatar(user: any, className = 'avatar-small') {
  264. return (
  265. <Avatar
  266. className = { className }
  267. size = { 32 }
  268. status = { user.status }
  269. url = { user.avatar } />
  270. );
  271. }
  272. /**
  273. * Processes results from requesting available numbers and people by munging
  274. * each result into a format {@code MultiSelectAutocomplete} can use for
  275. * display.
  276. *
  277. * @param {Array} response - The response object from the server for the
  278. * query.
  279. * @private
  280. * @returns {Object[]} Configuration objects for items to display in the
  281. * search autocomplete.
  282. */
  283. _parseQueryResults(response: IInvitee[] = []) {
  284. const { t, _dialOutEnabled } = this.props;
  285. const userTypes = [ INVITE_TYPES.USER, INVITE_TYPES.VIDEO_ROOM, INVITE_TYPES.ROOM ];
  286. const users = response.filter(item => userTypes.includes(item.type));
  287. const userDisplayItems = [];
  288. for (const user of users) {
  289. const { name, phone } = user;
  290. const tagAvatar = this._getAvatar(user, 'avatar-xsmall');
  291. const elemAvatar = this._getAvatar(user);
  292. userDisplayItems.push({
  293. content: name,
  294. elemBefore: elemAvatar,
  295. item: user,
  296. tag: {
  297. elemBefore: tagAvatar
  298. },
  299. value: user.id || user.user_id
  300. });
  301. if (phone && _dialOutEnabled) {
  302. userDisplayItems.push({
  303. filterValues: [ name, phone ],
  304. content: `${phone} (${name})`,
  305. elemBefore: elemAvatar,
  306. item: {
  307. type: INVITE_TYPES.PHONE,
  308. number: phone
  309. },
  310. tag: {
  311. elemBefore: tagAvatar
  312. },
  313. value: phone
  314. });
  315. }
  316. }
  317. const numbers = response.filter(item => item.type === INVITE_TYPES.PHONE);
  318. const telephoneIcon = this._renderTelephoneIcon();
  319. const numberDisplayItems = numbers.map(number => {
  320. const numberNotAllowedMessage
  321. = number.allowed ? '' : t('addPeople.countryNotSupported');
  322. const countryCodeReminder = number.showCountryCodeReminder
  323. ? t('addPeople.countryReminder') : '';
  324. const description
  325. = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
  326. return {
  327. filterValues: [
  328. number.originalEntry,
  329. number.number
  330. ],
  331. content: t('addPeople.telephone', { number: number.number }),
  332. description,
  333. isDisabled: !number.allowed,
  334. elemBefore: telephoneIcon,
  335. item: number,
  336. tag: {
  337. elemBefore: telephoneIcon
  338. },
  339. value: number.number
  340. };
  341. });
  342. const sipAddresses = response.filter(item => item.type === INVITE_TYPES.SIP);
  343. const sipDisplayItems = sipAddresses.map(sip => {
  344. return {
  345. filterValues: [
  346. sip.address
  347. ],
  348. content: sip.address,
  349. description: '',
  350. item: sip,
  351. value: sip.address
  352. };
  353. });
  354. return [
  355. ...userDisplayItems,
  356. ...numberDisplayItems,
  357. ...sipDisplayItems
  358. ];
  359. }
  360. /**
  361. * Clears the selected items from state and form.
  362. *
  363. * @returns {void}
  364. */
  365. _onClearItems() {
  366. if (this._multiselect) {
  367. this._multiselect.setSelectedItems([]);
  368. }
  369. this.setState({ inviteItems: [] });
  370. }
  371. /**
  372. * Clears the selected items from state and form.
  373. *
  374. * @param {KeyboardEvent} e - The key event to handle.
  375. *
  376. * @returns {void}
  377. */
  378. _onClearItemsKeyPress(e: KeyboardEvent) {
  379. if (e.key === ' ' || e.key === 'Enter') {
  380. e.preventDefault();
  381. this._onClearItems();
  382. }
  383. }
  384. /**
  385. * Renders the add/cancel actions for the form.
  386. *
  387. * @returns {ReactElement|null}
  388. */
  389. _renderFormActions() {
  390. const { inviteItems } = this.state;
  391. const { t, classes } = this.props;
  392. if (!inviteItems.length) {
  393. return null;
  394. }
  395. return (
  396. <div className = { classes.inviteButtons }>
  397. <Button
  398. aria-label = { t('dialog.Cancel') }
  399. className = 'invite-button'
  400. label = { t('dialog.Cancel') }
  401. onClick = { this._onClearItems }
  402. onKeyPress = { this._onClearItemsKeyPress }
  403. role = 'button'
  404. type = { BUTTON_TYPES.SECONDARY } />
  405. <Button
  406. aria-label = { t('addPeople.add') }
  407. className = 'invite-button'
  408. disabled = { this._isAddDisabled() }
  409. label = { t('addPeople.add') }
  410. onClick = { this._onSubmit }
  411. onKeyPress = { this._onSubmitKeyPress }
  412. role = 'button' />
  413. </div>
  414. );
  415. }
  416. /**
  417. * Renders a telephone icon.
  418. *
  419. * @private
  420. * @returns {ReactElement}
  421. */
  422. _renderTelephoneIcon() {
  423. return (
  424. <Icon src = { IconPhoneRinging } />
  425. );
  426. }
  427. /**
  428. * Sets the instance variable for the multi select component
  429. * element so it can be accessed directly.
  430. *
  431. * @param {MultiSelectAutocomplete} element - The DOM element for the component's dialog.
  432. * @private
  433. * @returns {void}
  434. */
  435. _setMultiSelectElement(element: MultiSelectAutocomplete) {
  436. this._multiselect = element;
  437. }
  438. }
  439. /**
  440. * Maps (parts of) the Redux state to the associated
  441. * {@code AddPeopleDialog}'s props.
  442. *
  443. * @param {IReduxState} state - The Redux state.
  444. * @private
  445. * @returns {Props}
  446. */
  447. function _mapStateToProps(state: IReduxState) {
  448. return {
  449. ..._abstractMapStateToProps(state),
  450. _isVpaas: isVpaasMeeting(state)
  451. };
  452. }
  453. export default translate(connect(_mapStateToProps)(withStyles(styles)(InviteContactsForm)));