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

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