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.js 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. // @flow
  2. import _ from 'lodash';
  3. import React from 'react';
  4. import {
  5. ActivityIndicator,
  6. FlatList,
  7. Platform,
  8. SafeAreaView,
  9. TextInput,
  10. TouchableOpacity,
  11. View
  12. } from 'react-native';
  13. import { ColorSchemeRegistry } from '../../../../base/color-scheme';
  14. import { AlertDialog, openDialog } from '../../../../base/dialog';
  15. import { translate } from '../../../../base/i18n';
  16. import {
  17. Icon,
  18. IconCancelSelection,
  19. IconCheck,
  20. IconClose,
  21. IconPhone,
  22. IconSearch,
  23. IconShare
  24. } from '../../../../base/icons';
  25. import { JitsiModal, setActiveModalId } from '../../../../base/modal';
  26. import {
  27. AvatarListItem,
  28. type Item
  29. } from '../../../../base/react';
  30. import { connect } from '../../../../base/redux';
  31. import { ColorPalette } from '../../../../base/styles';
  32. import { beginShareRoom } from '../../../../share-room';
  33. import { ADD_PEOPLE_DIALOG_VIEW_ID, INVITE_TYPES } from '../../../constants';
  34. import AbstractAddPeopleDialog, {
  35. type Props as AbstractProps,
  36. type State as AbstractState,
  37. _mapStateToProps as _abstractMapStateToProps
  38. } from '../AbstractAddPeopleDialog';
  39. import styles, {
  40. AVATAR_SIZE,
  41. DARK_GREY
  42. } from './styles';
  43. type Props = AbstractProps & {
  44. /**
  45. * The color schemed style of the Header.
  46. */
  47. _headerStyles: Object,
  48. /**
  49. * True if the invite dialog should be open, false otherwise.
  50. */
  51. _isVisible: boolean,
  52. /**
  53. * Function used to translate i18n labels.
  54. */
  55. t: Function
  56. };
  57. type State = AbstractState & {
  58. /**
  59. * Boolean to show if an extra padding needs to be added to the bottom bar.
  60. */
  61. bottomPadding: boolean,
  62. /**
  63. * State variable to keep track of the search field value.
  64. */
  65. fieldValue: string,
  66. /**
  67. * True if a search is in progress, false otherwise.
  68. */
  69. searchInprogress: boolean,
  70. /**
  71. * An array of items that are selectable on this dialog. This is usually
  72. * populated by an async search.
  73. */
  74. selectableItems: Array<Object>
  75. };
  76. /**
  77. * Implements a special dialog to invite people from a directory service.
  78. */
  79. class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
  80. /**
  81. * Default state object to reset the state to when needed.
  82. */
  83. defaultState = {
  84. addToCallError: false,
  85. addToCallInProgress: false,
  86. bottomPadding: false,
  87. fieldValue: '',
  88. inviteItems: [],
  89. searchInprogress: false,
  90. selectableItems: []
  91. };
  92. /**
  93. * Ref of the search field.
  94. */
  95. inputFieldRef: ?TextInput;
  96. /**
  97. * TimeoutID to delay the search for the time the user is probably typing.
  98. */
  99. searchTimeout: TimeoutID;
  100. /**
  101. * Contrustor of the component.
  102. *
  103. * @inheritdoc
  104. */
  105. constructor(props: Props) {
  106. super(props);
  107. this.state = this.defaultState;
  108. this._keyExtractor = this._keyExtractor.bind(this);
  109. this._renderInvitedItem = this._renderInvitedItem.bind(this);
  110. this._renderItem = this._renderItem.bind(this);
  111. this._renderSeparator = this._renderSeparator.bind(this);
  112. this._onClearField = this._onClearField.bind(this);
  113. this._onInvite = this._onInvite.bind(this);
  114. this._onPressItem = this._onPressItem.bind(this);
  115. this._onShareMeeting = this._onShareMeeting.bind(this);
  116. this._onTypeQuery = this._onTypeQuery.bind(this);
  117. this._renderShareMeetingButton = this._renderShareMeetingButton.bind(this);
  118. this._setFieldRef = this._setFieldRef.bind(this);
  119. }
  120. /**
  121. * Implements {@code Component#componentDidUpdate}.
  122. *
  123. * @inheritdoc
  124. */
  125. componentDidUpdate(prevProps) {
  126. if (prevProps._isVisible !== this.props._isVisible) {
  127. // Clear state
  128. this._clearState();
  129. }
  130. }
  131. /**
  132. * Implements {@code Component#render}.
  133. *
  134. * @inheritdoc
  135. */
  136. render() {
  137. const {
  138. _addPeopleEnabled,
  139. _dialOutEnabled
  140. } = this.props;
  141. const { inviteItems, selectableItems } = this.state;
  142. let placeholderKey = 'searchPlaceholder';
  143. if (!_addPeopleEnabled) {
  144. placeholderKey = 'searchCallOnlyPlaceholder';
  145. } else if (!_dialOutEnabled) {
  146. placeholderKey = 'searchPeopleOnlyPlaceholder';
  147. }
  148. return (
  149. <JitsiModal
  150. footerComponent = { this._renderShareMeetingButton }
  151. headerProps = {{
  152. forwardDisabled: this._isAddDisabled(),
  153. forwardLabelKey: 'inviteDialog.send',
  154. headerLabelKey: 'inviteDialog.header',
  155. onPressForward: this._onInvite
  156. }}
  157. modalId = { ADD_PEOPLE_DIALOG_VIEW_ID }>
  158. <View
  159. style = { styles.searchFieldWrapper }>
  160. <View style = { styles.searchIconWrapper }>
  161. { this.state.searchInprogress
  162. ? <ActivityIndicator
  163. color = { DARK_GREY }
  164. size = 'small' />
  165. : <Icon
  166. src = { IconSearch }
  167. style = { styles.searchIcon } />}
  168. </View>
  169. <TextInput
  170. autoCorrect = { false }
  171. autoFocus = { false }
  172. onBlur = { this._onFocused(false) }
  173. onChangeText = { this._onTypeQuery }
  174. onFocus = { this._onFocused(true) }
  175. placeholder = {
  176. this.props.t(`inviteDialog.${placeholderKey}`)
  177. }
  178. placeholderTextColor = { ColorPalette.lightGrey }
  179. ref = { this._setFieldRef }
  180. spellCheck = { false }
  181. style = { styles.searchField }
  182. value = { this.state.fieldValue } />
  183. { this._renderClearButton() }
  184. </View>
  185. { Boolean(inviteItems.length) && <View style = { styles.invitedList }>
  186. <FlatList
  187. data = { inviteItems }
  188. horizontal = { true }
  189. keyExtractor = { this._keyExtractor }
  190. keyboardShouldPersistTaps = 'always'
  191. renderItem = { this._renderInvitedItem } />
  192. </View> }
  193. <View style = { styles.resultList }>
  194. <FlatList
  195. ItemSeparatorComponent = { this._renderSeparator }
  196. data = { selectableItems }
  197. extraData = { inviteItems }
  198. keyExtractor = { this._keyExtractor }
  199. keyboardShouldPersistTaps = 'always'
  200. renderItem = { this._renderItem } />
  201. </View>
  202. </JitsiModal>
  203. );
  204. }
  205. /**
  206. * Clears the dialog content.
  207. *
  208. * @returns {void}
  209. */
  210. _clearState() {
  211. this.setState(this.defaultState);
  212. }
  213. /**
  214. * Returns an object capable of being rendered by an {@code AvatarListItem}.
  215. *
  216. * @param {Object} flatListItem - An item of the data array of the {@code FlatList}.
  217. * @returns {?Object}
  218. */
  219. _getRenderableItem(flatListItem) {
  220. const { item } = flatListItem;
  221. switch (item.type) {
  222. case INVITE_TYPES.PHONE:
  223. return {
  224. avatar: IconPhone,
  225. key: item.number,
  226. title: item.number
  227. };
  228. case INVITE_TYPES.USER:
  229. return {
  230. avatar: item.avatar,
  231. key: item.id || item.user_id,
  232. title: item.name
  233. };
  234. default:
  235. return null;
  236. }
  237. }
  238. _invite: Array<Object> => Promise<Array<Object>>
  239. _isAddDisabled: () => boolean;
  240. _keyExtractor: Object => string
  241. /**
  242. * Key extractor for the flatlist.
  243. *
  244. * @param {Object} item - The flatlist item that we need the key to be
  245. * generated for.
  246. * @returns {string}
  247. */
  248. _keyExtractor(item) {
  249. return item.type === INVITE_TYPES.USER ? item.id || item.user_id : item.number;
  250. }
  251. _onClearField: () => void
  252. /**
  253. * Callback to clear the text field.
  254. *
  255. * @returns {void}
  256. */
  257. _onClearField() {
  258. this.setState({
  259. fieldValue: ''
  260. });
  261. // Clear search results
  262. this._onTypeQuery('');
  263. }
  264. _onFocused: boolean => Function;
  265. /**
  266. * Constructs a callback to be used to update the padding of the field if necessary.
  267. *
  268. * @param {boolean} focused - True of the field is focused.
  269. * @returns {Function}
  270. */
  271. _onFocused(focused) {
  272. return () => {
  273. Platform.OS === 'android' && this.setState({
  274. bottomPadding: focused
  275. });
  276. };
  277. }
  278. _onInvite: () => void
  279. /**
  280. * Invites the selected entries.
  281. *
  282. * @returns {void}
  283. */
  284. _onInvite() {
  285. this._invite(this.state.inviteItems)
  286. .then(invitesLeftToSend => {
  287. if (invitesLeftToSend.length) {
  288. this.setState({
  289. inviteItems: invitesLeftToSend
  290. });
  291. this._showFailedInviteAlert();
  292. } else {
  293. this.props.dispatch(setActiveModalId());
  294. }
  295. });
  296. }
  297. _onPressItem: Item => Function
  298. /**
  299. * Function to prepare a callback for the onPress event of the touchable.
  300. *
  301. * @param {Item} item - The item on which onPress was invoked.
  302. * @returns {Function}
  303. */
  304. _onPressItem(item) {
  305. return () => {
  306. const { inviteItems } = this.state;
  307. const finderKey = item.type === INVITE_TYPES.PHONE ? 'number' : 'user_id';
  308. if (inviteItems.find(
  309. _.matchesProperty(finderKey, item[finderKey]))) {
  310. // Item is already selected, need to unselect it.
  311. this.setState({
  312. inviteItems: inviteItems.filter(
  313. element => item[finderKey] !== element[finderKey])
  314. });
  315. } else {
  316. // Item is not selected yet, need to add to the list.
  317. const items: Array<Object> = inviteItems.concat(item);
  318. this.setState({
  319. inviteItems: _.sortBy(items, [ 'name', 'number' ])
  320. });
  321. }
  322. };
  323. }
  324. _onShareMeeting: () => void
  325. /**
  326. * Shows the system share sheet to share the meeting information.
  327. *
  328. * @returns {void}
  329. */
  330. _onShareMeeting() {
  331. if (this.state.inviteItems.length > 0) {
  332. // The use probably intended to invite people.
  333. this._onInvite();
  334. } else {
  335. this.props.dispatch(beginShareRoom());
  336. }
  337. }
  338. _onTypeQuery: string => void
  339. /**
  340. * Handles the typing event of the text field on the dialog and performs the
  341. * search.
  342. *
  343. * @param {string} query - The query that is typed in the field.
  344. * @returns {void}
  345. */
  346. _onTypeQuery(query) {
  347. this.setState({
  348. fieldValue: query
  349. });
  350. clearTimeout(this.searchTimeout);
  351. this.searchTimeout = setTimeout(() => {
  352. this.setState({
  353. searchInprogress: true
  354. }, () => {
  355. this._performSearch(query);
  356. });
  357. }, 500);
  358. }
  359. /**
  360. * Performs the actual search.
  361. *
  362. * @param {string} query - The query to search for.
  363. * @returns {void}
  364. */
  365. _performSearch(query) {
  366. this._query(query).then(results => {
  367. this.setState({
  368. selectableItems: _.sortBy(results, [ 'name', 'number' ])
  369. });
  370. })
  371. .finally(() => {
  372. this.setState({
  373. searchInprogress: false
  374. }, () => {
  375. this.inputFieldRef && this.inputFieldRef.focus();
  376. });
  377. });
  378. }
  379. _query: (string) => Promise<Array<Object>>;
  380. /**
  381. * Renders a button to clear the text field.
  382. *
  383. * @returns {React#Element<*>}
  384. */
  385. _renderClearButton() {
  386. if (!this.state.fieldValue.length) {
  387. return null;
  388. }
  389. return (
  390. <TouchableOpacity
  391. onPress = { this._onClearField }
  392. style = { styles.clearButton }>
  393. <View style = { styles.clearIconContainer }>
  394. <Icon
  395. src = { IconClose }
  396. style = { styles.clearIcon } />
  397. </View>
  398. </TouchableOpacity>
  399. );
  400. }
  401. _renderInvitedItem: Object => React$Element<any> | null
  402. /**
  403. * Renders a single item in the invited {@code FlatList}.
  404. *
  405. * @param {Object} flatListItem - An item of the data array of the
  406. * {@code FlatList}.
  407. * @param {number} index - The index of the currently rendered item.
  408. * @returns {?React$Element<any>}
  409. */
  410. _renderInvitedItem(flatListItem, index): React$Element<any> | null {
  411. const { item } = flatListItem;
  412. const renderableItem = this._getRenderableItem(flatListItem);
  413. return (
  414. <TouchableOpacity onPress = { this._onPressItem(item) } >
  415. <View
  416. pointerEvents = 'box-only'
  417. style = { styles.itemWrapper }>
  418. <AvatarListItem
  419. avatarOnly = { true }
  420. avatarSize = { AVATAR_SIZE }
  421. avatarStatus = { item.status }
  422. avatarStyle = { styles.avatar }
  423. avatarTextStyle = { styles.avatarText }
  424. item = { renderableItem }
  425. key = { index }
  426. linesStyle = { styles.itemLinesStyle }
  427. titleStyle = { styles.itemText } />
  428. <Icon
  429. src = { IconCancelSelection }
  430. style = { styles.unselectIcon } />
  431. </View>
  432. </TouchableOpacity>
  433. );
  434. }
  435. _renderItem: Object => React$Element<any> | null
  436. /**
  437. * Renders a single item in the search result {@code FlatList}.
  438. *
  439. * @param {Object} flatListItem - An item of the data array of the
  440. * {@code FlatList}.
  441. * @param {number} index - The index of the currently rendered item.
  442. * @returns {?React$Element<*>}
  443. */
  444. _renderItem(flatListItem, index): React$Element<any> | null {
  445. const { item } = flatListItem;
  446. const { inviteItems } = this.state;
  447. let selected = false;
  448. const renderableItem = this._getRenderableItem(flatListItem);
  449. if (!renderableItem) {
  450. return null;
  451. }
  452. switch (item.type) {
  453. case INVITE_TYPES.PHONE:
  454. selected = inviteItems.find(_.matchesProperty('number', item.number));
  455. break;
  456. case INVITE_TYPES.USER:
  457. selected = item.id
  458. ? inviteItems.find(_.matchesProperty('id', item.id))
  459. : inviteItems.find(_.matchesProperty('user_id', item.user_id));
  460. break;
  461. default:
  462. return null;
  463. }
  464. return (
  465. <TouchableOpacity onPress = { this._onPressItem(item) } >
  466. <View
  467. pointerEvents = 'box-only'
  468. style = { styles.itemWrapper }>
  469. <AvatarListItem
  470. avatarSize = { AVATAR_SIZE }
  471. avatarStatus = { item.status }
  472. avatarStyle = { styles.avatar }
  473. avatarTextStyle = { styles.avatarText }
  474. item = { renderableItem }
  475. key = { index }
  476. linesStyle = { styles.itemLinesStyle }
  477. titleStyle = { styles.itemText } />
  478. { selected && <Icon
  479. src = { IconCheck }
  480. style = { styles.selectedIcon } /> }
  481. </View>
  482. </TouchableOpacity>
  483. );
  484. }
  485. _renderSeparator: () => React$Element<*> | null
  486. /**
  487. * Renders the item separator.
  488. *
  489. * @returns {?React$Element<*>}
  490. */
  491. _renderSeparator() {
  492. return (
  493. <View style = { styles.separator } />
  494. );
  495. }
  496. _renderShareMeetingButton: () => React$Element<any>;
  497. /**
  498. * Renders a button to share the meeting info.
  499. *
  500. * @returns {React#Element<*>}
  501. */
  502. _renderShareMeetingButton() {
  503. const { _headerStyles } = this.props;
  504. return (
  505. <SafeAreaView
  506. style = { [
  507. styles.bottomBar,
  508. _headerStyles.headerOverlay,
  509. this.state.bottomPadding ? styles.extraBarPadding : null
  510. ] }>
  511. <TouchableOpacity
  512. onPress = { this._onShareMeeting }>
  513. <Icon
  514. src = { IconShare }
  515. style = { [ _headerStyles.headerButtonText, styles.shareIcon ] } />
  516. </TouchableOpacity>
  517. </SafeAreaView>
  518. );
  519. }
  520. _setFieldRef: ?TextInput => void
  521. /**
  522. * Sets a reference to the input field for later use.
  523. *
  524. * @param {?TextInput} input - The reference to the input field.
  525. * @returns {void}
  526. */
  527. _setFieldRef(input) {
  528. this.inputFieldRef = input;
  529. }
  530. /**
  531. * Shows an alert telling the user that some invitees were failed to be
  532. * invited.
  533. *
  534. * NOTE: We're using an Alert here because we're on a modal and it makes
  535. * using our dialogs a tad more difficult.
  536. *
  537. * @returns {void}
  538. */
  539. _showFailedInviteAlert() {
  540. this.props.dispatch(openDialog(AlertDialog, {
  541. contentKey: {
  542. key: 'inviteDialog.alertText'
  543. }
  544. }));
  545. }
  546. }
  547. /**
  548. * Maps part of the Redux state to the props of this component.
  549. *
  550. * @param {Object} state - The Redux state.
  551. * @returns {{
  552. * _isVisible: boolean
  553. * }}
  554. */
  555. function _mapStateToProps(state: Object) {
  556. return {
  557. ..._abstractMapStateToProps(state),
  558. _headerStyles: ColorSchemeRegistry.get(state, 'Header'),
  559. _isVisible: state['features/base/modal'].activeModalId === ADD_PEOPLE_DIALOG_VIEW_ID
  560. };
  561. }
  562. export default translate(connect(_mapStateToProps)(AddPeopleDialog));