diff --git a/app/components/custom_flat_list.js b/app/components/custom_flat_list.js deleted file mode 100644 index 8524732dc..000000000 --- a/app/components/custom_flat_list.js +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {FlatList, Platform, View} from 'react-native'; - -import Loading from 'app/components/loading'; -import FormattedText from 'app/components/formatted_text'; -import {emptyFunction} from 'app/utils/general'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; - -export default class CustomFlatList extends PureComponent { - static propTypes = { - - /* - * The current theme. - */ - theme: PropTypes.object.isRequired, - - /* - * An array of items to be rendered. - */ - items: PropTypes.array.isRequired, - - /* - * A function or React element used to render the items in the list. - */ - renderItem: PropTypes.func, - - /* - * Whether or not to render "No results" when the list contains no items. - */ - showNoResults: PropTypes.bool, - - /* - * A function to get a unique key for each item in the list. If not provided, the id field of the item will be used as the key. - */ - keyExtractor: PropTypes.func, - - /* - * Any extra data needed to render the list. If this value changes, all items of a list will be re-rendered. - */ - extraData: PropTypes.object, - - /* - * A function called when an item in the list is pressed. Receives the item that was pressed as an argument. - */ - onRowPress: PropTypes.func, - - /* - * A function called when the end of the list is reached. This can be triggered before this list end is reached - * by changing onListEndReachedThreshold. - */ - onListEndReached: PropTypes.func, - - /* - * How soon before the end of the list onListEndReached should be called. - */ - onListEndReachedThreshold: PropTypes.number, - - /* - * Whether or not to display the loading text. - */ - loading: PropTypes.bool, - - /* - * The text displayed when loading is set to true. - */ - loadingText: PropTypes.object, - - /* - * How many items to render when the list is first rendered. - */ - initialNumToRender: PropTypes.number, - }; - - static defaultProps = { - showNoResults: true, - keyExtractor: (item) => { - return item.id; - }, - onListEndReached: emptyFunction, - onListEndReachedThreshold: 50, - loadingText: null, - initialNumToRender: 10, - }; - - constructor(props) { - super(props); - - this.state = { - items: props.items, - }; - } - - componentWillReceiveProps(nextProps) { - if (nextProps.items !== this.props.items) { - this.setState({ - items: nextProps.items, - }); - } - } - - renderItem = ({item}) => { - const props = { - id: item.id, - item, - onPress: this.props.onRowPress, - }; - - // Allow passing in a component like UserListRow or ChannelListRow - if (this.props.renderItem.prototype.isReactElement) { - const RowComponent = this.props.renderItem; - return ; - } - - return this.props.renderItem(props); - }; - - renderItemSeparator = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - return ; - }; - - renderFooter = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - if (!this.props.loading || !this.props.loadingText) { - return null; - } - - if (this.props.items.length === 0) { - return null; - } - - return ( - - - - ); - }; - - renderEmptyList = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - if (this.props.loading) { - return ( - - - - ); - } - - if (this.props.showNoResults) { - return ( - - - - ); - } - - return null; - }; - - render() { - const style = getStyleFromTheme(this.props.theme); - - return ( - - ); - } -} - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - listView: { - flex: 1, - backgroundColor: theme.centerChannelBg, - ...Platform.select({ - android: { - marginBottom: 20, - }, - }), - }, - loading: { - height: 70, - backgroundColor: theme.centerChannelBg, - alignItems: 'center', - justifyContent: 'center', - }, - loadingText: { - color: changeOpacity(theme.centerChannelColor, 0.6), - }, - searching: { - backgroundColor: theme.centerChannelBg, - height: '100%', - position: 'absolute', - width: '100%', - }, - separator: { - height: 1, - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - }, - noResultContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - noResultText: { - fontSize: 26, - color: changeOpacity(theme.centerChannelColor, 0.5), - }, - }; -}); diff --git a/app/components/custom_list/__snapshots__/index.test.js.snap b/app/components/custom_list/__snapshots__/index.test.js.snap index f23e638ea..9a9d9528f 100644 --- a/app/components/custom_list/__snapshots__/index.test.js.snap +++ b/app/components/custom_list/__snapshots__/index.test.js.snap @@ -1,64 +1,102 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CustomList should match snapshot 1`] = ` - - +`; + +exports[`CustomList should match snapshot with SectionList 1`] = ` + - + } + updateCellsBatchingPeriod={50} + windowSize={21} +/> `; exports[`CustomList should match snapshot, renderFooter 1`] = `null`; -exports[`CustomList should match snapshot, renderFooter 2`] = ` - - - -`; +exports[`CustomList should match snapshot, renderFooter 2`] = ``; exports[`CustomList should match snapshot, renderSectionHeader 1`] = ` true, - onListEndReachedThreshold: 50, - listPageSize: 10, - listInitialSize: 10, - listScrollRenderAheadDistance: 200, - loadingText: null, - selectable: false, - createSections: () => true, - showSections: true, + listType: FLATLIST, showNoResults: true, }; constructor(props) { super(props); - this.state = this.buildDataSource(props); + this.contentOffsetY = 0; + this.state = {}; } - componentWillReceiveProps(nextProps) { - const {data, showSections, searching} = nextProps; + handleLayout = (event) => { + const {height} = event.nativeEvent.layout; + this.setState({listHeight: height}); + }; - if (searching || searching !== this.props.searching) { - this.setState(this.buildDataSource(nextProps)); - } else if (data !== this.props.data || showSections !== this.props.showSections) { - let newData = data; - if (showSections) { - newData = this.props.createSections(data); + handleScroll = (event) => { + const pageOffsetY = event.nativeEvent.contentOffset.y; + if (pageOffsetY > 0) { + const contentHeight = event.nativeEvent.contentSize.height; + const direction = (this.contentOffsetY < pageOffsetY) ? + ListTypes.VISIBILITY_SCROLL_UP : + ListTypes.VISIBILITY_SCROLL_DOWN; + + this.contentOffsetY = pageOffsetY; + if ( + direction === ListTypes.VISIBILITY_SCROLL_UP && + (contentHeight - pageOffsetY) < (this.state.listHeight * SCROLL_UP_MULTIPLIER) + ) { + this.props.onLoadMore(); } - - const dataSource = showSections ? this.state.dataSource.cloneWithRowsAndSections(newData) : this.state.dataSource.cloneWithRows(newData); - this.setState({ - data: newData, - dataSource, - }); } - } - - buildDataSource = (props) => { - const ds = new ListView.DataSource({ - rowHasChanged: (r1, r2) => r1 !== r2, - sectionHeaderHasChanged: (s1, s2) => s1 !== s2, - }); - let newData = props.data; - if (props.showSections) { - newData = this.props.createSections(props.data); - } - const dataSource = props.showSections ? ds.cloneWithRowsAndSections(newData) : ds.cloneWithRows(newData); - return { - data: newData, - dataSource, - }; }; - handleRowSelect = (sectionId, rowId) => { - const data = this.state.data; - const section = [...data[sectionId]]; - - section[rowId] = Object.assign({}, section[rowId], {selected: !section[rowId].selected}); - const mergedData = Object.assign({}, data, {[sectionId]: section}); - - const id = section[rowId].id; - - const dataSource = this.state.dataSource.cloneWithRowsAndSections(mergedData); - this.setState({ - data: mergedData, - dataSource, - }, () => this.props.onRowSelect(id)); + keyExtractor = (item) => { + return item.id || item.key || item.value || item; }; - renderSectionHeader = (sectionData, sectionId) => { - const style = getStyleFromTheme(this.props.theme); - return ( - - - {sectionId} - - - ); + renderEmptyList = () => { + return this.props.noResults || null; }; - renderRow = (item, sectionId, rowId) => { + renderItem = ({item, index, section}) => { + const {onRowPress, onRowSelect, selectable} = this.props; const props = { id: item.id, item, selected: item.selected, - selectable: this.props.selectable, - onPress: this.props.onRowPress, + selectable, }; if ('disableSelect' in item) { props.enabled = !item.disableSelect; } - if (this.props.onRowSelect) { - props.onPress = this.handleRowSelect.bind(this, sectionId, rowId); + if (onRowSelect) { + props.onPress = onRowSelect(section.title, index); } else { - props.onPress = this.props.onRowPress; + props.onPress = onRowPress; } // Allow passing in a component like UserListRow or ChannelListRow - if (this.props.renderRow.prototype.isReactComponent) { - const RowComponent = this.props.renderRow; + if (this.props.renderItem.prototype.isReactComponent) { + const RowComponent = this.props.renderItem; return ; } - return this.props.renderRow(props); + return this.props.renderItem(props); }; - renderSeparator = (sectionId, rowId) => { - const style = getStyleFromTheme(this.props.theme); + renderFlatList = () => { + const {data, extraData, theme} = this.props; + const style = getStyleFromTheme(theme); + return ( - ); }; renderFooter = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - if (!this.props.loading || !this.props.loadingText) { + const {loading, loadingComponent} = this.props; + + if (!loading || !loadingComponent) { return null; } - const backgroundColor = this.props.data.length > 0 ? theme.centerChannelBg : '#0000'; + return loadingComponent; + }; + + renderSectionHeader = ({section}) => { + const style = getStyleFromTheme(this.props.theme); return ( - - + + + {section.id} + ); }; - render() { - const { - data, - listInitialSize, - listPageSize, - listScrollRenderAheadDistance, - loading, - onListEndReached, - onListEndReachedThreshold, - searching, - showNoResults, - showSections, - theme, - } = this.props; - const {dataSource} = this.state; + renderSectionList = () => { + const {data, loading, theme} = this.props; const style = getStyleFromTheme(theme); - let noResults = false; - - if (typeof data === 'object') { - noResults = Object.keys(data).length === 0; - } else { - noResults = data.length === 0; - } - - let loadingComponent; - if (loading && searching) { - loadingComponent = ( - - - - ); - } - - if (showNoResults && noResults) { - return ( - - - - ); - } return ( - - - {loadingComponent} - + ); + }; + + renderSeparator = () => { + const style = getStyleFromTheme(this.props.theme); + + return ( + + ); + }; + + setListRef = (ref) => { + this.list = ref; + }; + + render() { + const {listType} = this.props; + + if (listType === FLATLIST) { + return this.renderFlatList(); + } + + return this.renderSectionList(); } } const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { - container: { + list: { + backgroundColor: theme.centerChannelBg, flex: 1, + ...Platform.select({ + android: { + marginBottom: 20, + }, + }), + }, + container: { + flexGrow: 1, + }, + separator: { + height: 1, + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), }, listView: { flex: 1, @@ -272,11 +256,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { fontWeight: '600', color: theme.centerChannelColor, }, - separator: { - height: 1, - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - }, noResultContainer: { flex: 1, flexDirection: 'row', diff --git a/app/components/custom_list/index.test.js b/app/components/custom_list/index.test.js index 2dbe15aa8..fd7b4d0f6 100644 --- a/app/components/custom_list/index.test.js +++ b/app/components/custom_list/index.test.js @@ -2,86 +2,75 @@ // See LICENSE.txt for license information. import React from 'react'; +import {View} from 'react-native'; import {shallow} from 'enzyme'; import Preferences from 'mattermost-redux/constants/preferences'; -import {createMembersSections, loadingText} from 'app/utils/member_list'; - -import CustomList from './index'; +import CustomList, {FLATLIST, SECTIONLIST} from './index'; describe('CustomList', () => { - function emptyFunc() {} // eslint-disable-line no-empty-function - const baseProps = { data: [{username: 'username_1'}, {username: 'username_2'}], - theme: Preferences.THEMES.default, - searching: false, - onListEndReached: emptyFunc, - onListEndReachedThreshold: 0, + listType: FLATLIST, loading: false, - loadingText, - listPageSize: 0, + loadingComponent: (), + onLoadMore: jest.fn(), + onRowPress: jest.fn(), + onRowSelect: null, + renderItem: jest.fn(), listInitialSize: 0, listScrollRenderAheadDistance: 0, showSections: true, - onRowPress: emptyFunc, selectable: true, - onRowSelect: emptyFunc, - renderRow: emptyFunc, - createSections: createMembersSections, - showNoResults: false, + theme: Preferences.THEMES.default, }; - test('should match snapshot', () => { + test('should match snapshot with FlatList', () => { const wrapper = shallow( ); expect(wrapper.getElement()).toMatchSnapshot(); - expect(wrapper.state('data')).toEqual({U: baseProps.data}); - - // on componentWillReceiveProps - const newDataProps = [{username: 'username_1'}, {username: 'username_2'}, {username: 'username_3'}]; - wrapper.setProps({data: newDataProps}); - expect(wrapper.state('data')).toEqual({U: newDataProps}); + expect(wrapper.find('FlatList')).toHaveLength(1); }); - test('should match return value on buildDataSource', () => { - const props = {...baseProps}; + test('should match snapshot with SectionList', () => { + const props = { + ...baseProps, + listType: SECTIONLIST, + }; + const wrapper = shallow( ); - - expect(wrapper.instance().buildDataSource(props).data).toEqual({U: [{username: 'username_1'}, {username: 'username_2'}]}); - const newDataProps = [{username: 'aaa_1'}, {username: 'aaa_2'}, {username: 'username_1'}, {username: 'username_2'}, {username: 'username_3'}]; - props.data = newDataProps; - expect(wrapper.instance().buildDataSource(props).data).toEqual({ - A: [{username: 'aaa_1'}, {username: 'aaa_2'}], - U: [{username: 'username_1'}, {username: 'username_2'}, {username: 'username_3'}], - }); + expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.find('SectionList')).toHaveLength(1); }); test('should match snapshot, renderSectionHeader', () => { const wrapper = shallow( ); - expect(wrapper.instance().renderSectionHeader([], 'section_id')).toMatchSnapshot(); + const section = { + id: 'section_id', + }; + expect(wrapper.instance().renderSectionHeader({section})).toMatchSnapshot(); }); - test('should call props.renderRow on renderRow', () => { - const props = {...baseProps, renderRow: jest.fn()}; + test('should call props.renderItem on renderItem', () => { + const props = {...baseProps}; const wrapper = shallow( ); - wrapper.instance().renderRow({id: 'item_id', selected: true}, 'section_id', 'row_id'); - expect(props.renderRow).toHaveBeenCalledTimes(1); + wrapper.instance().renderItem({item: {id: 'item_id', selected: true}, index: 0, section: null}); + expect(props.renderItem).toHaveBeenCalledTimes(1); }); test('should match snapshot, renderSeparator', () => { const wrapper = shallow( ); - expect(wrapper.instance().renderSeparator('section_id', 'row_id')).toMatchSnapshot(); + expect(wrapper.instance().renderSeparator()).toMatchSnapshot(); }); test('should match snapshot, renderFooter', () => { diff --git a/app/components/custom_list/option_list_row/option_list_row.js b/app/components/custom_list/option_list_row/option_list_row.js index 8b9cb38bf..680b7424b 100644 --- a/app/components/custom_list/option_list_row/option_list_row.js +++ b/app/components/custom_list/option_list_row/option_list_row.js @@ -70,6 +70,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { alignItems: 'center', backgroundColor: theme.centerChannelBg, }, + textContainer: { + marginLeft: 10, + justifyContent: 'center', + flexDirection: 'column', + flex: 1, + }, optionText: { fontSize: 15, color: theme.centerChannelColor, diff --git a/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap b/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap index 9e83da0b4..d62f004a7 100644 --- a/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap +++ b/app/components/custom_list/user_list_row/__snapshots__/user_list_test.test.js.snap @@ -7,6 +7,7 @@ exports[`UserListRow should match snapshot 1`] = ` "flex": 1, "flexDirection": "row", "marginHorizontal": 10, + "overflow": "hidden", } } > @@ -65,6 +66,7 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated "flex": 1, "flexDirection": "row", "marginHorizontal": 10, + "overflow": "hidden", } } > @@ -121,6 +123,7 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = ` "flex": 1, "flexDirection": "row", "marginHorizontal": 10, + "overflow": "hidden", } } > diff --git a/app/components/custom_list/user_list_row/user_list_row.js b/app/components/custom_list/user_list_row/user_list_row.js index 68f6b08fa..39bade2d4 100644 --- a/app/components/custom_list/user_list_row/user_list_row.js +++ b/app/components/custom_list/user_list_row/user_list_row.js @@ -119,6 +119,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { flex: 1, flexDirection: 'row', marginHorizontal: 10, + overflow: 'hidden', }, profileContainer: { flexDirection: 'row', diff --git a/app/components/custom_section_list.js b/app/components/custom_section_list.js deleted file mode 100644 index c95a2ffa1..000000000 --- a/app/components/custom_section_list.js +++ /dev/null @@ -1,306 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import React from 'react'; -import PropTypes from 'prop-types'; -import { - Platform, - SectionList, - Text, - View, -} from 'react-native'; - -import Loading from 'app/components/loading'; -import FormattedText from 'app/components/formatted_text'; -import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; - -export default class CustomSectionList extends React.PureComponent { - static propTypes = { - - /* - * The current theme. - */ - theme: PropTypes.object.isRequired, - - /* - * An array of items to be rendered. - */ - items: PropTypes.array.isRequired, - - /* - * A function or React element used to render the items in the list. - */ - renderItem: PropTypes.func, - - /* - * Whether or not to render "No results" when the list contains no items. - */ - showNoResults: PropTypes.bool, - - /* - * A function to get a section identifier for each item in the list. Items sharing a section identifier will be grouped together. - */ - sectionKeyExtractor: PropTypes.func.isRequired, - - /* - * A comparison function used when sorting items in the list. - */ - compareItems: PropTypes.func.isRequired, - - /* - * A function to get a unique key for each item in the list. If not provided, the id field of the item will be used as the key. - */ - keyExtractor: PropTypes.func, - - /* - * Any extra data needed to render the list. If this value changes, all items of a list will be re-rendered. - */ - extraData: PropTypes.object, - - /* - * A function called when an item in the list is pressed. Receives the item that was pressed as an argument. - */ - onRowPress: PropTypes.func, - - /* - * A function called when the end of the list is reached. This can be triggered before this list end is reached - * by changing onListEndReachedThreshold. - */ - onListEndReached: PropTypes.func, - - /* - * How soon before the end of the list onListEndReached should be called. - */ - onListEndReachedThreshold: PropTypes.number, - - /* - * Whether or not to display the loading text. - */ - loading: PropTypes.bool, - - /* - * The text displayed when loading is set to true. - */ - loadingText: PropTypes.object, - - /* - * How many items to render when the list is first rendered. - */ - initialNumToRender: PropTypes.number, - }; - - static defaultProps = { - showNoResults: true, - keyExtractor: (item) => { - return item.id; - }, - onListEndReached: () => true, - onListEndReachedThreshold: 50, - loadingText: null, - initialNumToRender: 10, - }; - - constructor(props) { - super(props); - - this.state = { - sections: this.extractSections(props.items), - }; - } - - componentWillReceiveProps(nextProps) { - if (nextProps.items !== this.props.items) { - this.setState({ - sections: this.extractSections(nextProps.items), - }); - } - } - - extractSections = (items) => { - const sections = {}; - const sectionKeys = []; - for (const item of items) { - const sectionKey = this.props.sectionKeyExtractor(item); - - if (!sections[sectionKey]) { - sections[sectionKey] = []; - sectionKeys.push(sectionKey); - } - - sections[sectionKey].push(item); - } - - sectionKeys.sort(); - - return sectionKeys.map((sectionKey) => { - return { - key: sectionKey, - data: sections[sectionKey].sort(this.props.compareItems), - }; - }); - } - - renderSectionHeader = ({section}) => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - return ( - - - {section.key} - - - ); - }; - - renderItem = ({item}) => { - const props = { - id: item.id, - item, - onPress: this.props.onRowPress, - }; - - // Allow passing in a component like UserListRow or ChannelListRow - if (this.props.renderItem.prototype.isReactElement) { - const RowComponent = this.props.renderItem; - return ; - } - - return this.props.renderItem(props); - }; - - renderItemSeparator = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - return ( - - ); - }; - - renderFooter = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - if (!this.props.loading || !this.props.loadingText) { - return null; - } - - if (this.props.items.length === 0) { - return null; - } - - return ( - - - - ); - }; - - renderEmptyList = () => { - const {theme} = this.props; - const style = getStyleFromTheme(theme); - - if (this.props.loading) { - return ( - - - - ); - } - - if (this.props.showNoResults) { - return ( - - - - ); - } - - return null; - } - - render() { - const style = getStyleFromTheme(this.props.theme); - - return ( - - ); - } -} - -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - listView: { - ...Platform.select({ - android: { - marginBottom: 20, - }, - }), - }, - loading: { - height: 70, - backgroundColor: theme.centerChannelBg, - alignItems: 'center', - justifyContent: 'center', - }, - loadingText: { - color: changeOpacity(theme.centerChannelColor, 0.6), - }, - searching: { - backgroundColor: theme.centerChannelBg, - height: '100%', - position: 'absolute', - width: '100%', - }, - sectionContainer: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), - paddingLeft: 10, - paddingVertical: 2, - }, - sectionWrapper: { - backgroundColor: theme.centerChannelBg, - }, - sectionText: { - fontWeight: '600', - color: theme.centerChannelColor, - }, - separator: { - height: 1, - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - }, - noResultContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - noResultText: { - fontSize: 26, - color: changeOpacity(theme.centerChannelColor, 0.5), - }, - }; -}); diff --git a/app/components/edit_channel_info/index.js b/app/components/edit_channel_info/index.js index 50f8d9cc6..f0ca0dd7a 100644 --- a/app/components/edit_channel_info/index.js +++ b/app/components/edit_channel_info/index.js @@ -12,8 +12,6 @@ import { } from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; - import ErrorText from 'app/components/error_text'; import FormattedText from 'app/components/formatted_text'; import Loading from 'app/components/loading'; @@ -92,7 +90,6 @@ export default class EditChannelInfo extends PureComponent { }; close = (goBack = false) => { - EventEmitter.emit('closing-create-channel', false); if (goBack) { this.props.navigator.pop({animated: true}); } else { diff --git a/app/screens/channel_add_members/channel_add_members.js b/app/screens/channel_add_members/channel_add_members.js index 60d8a760f..67ce5153d 100644 --- a/app/screens/channel_add_members/channel_add_members.js +++ b/app/screens/channel_add_members/channel_add_members.js @@ -3,70 +3,70 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; +import {intlShape} from 'react-intl'; import { Alert, - InteractionManager, Platform, View, } from 'react-native'; +import {debounce} from 'mattermost-redux/actions/helpers'; +import {General} from 'mattermost-redux/constants'; +import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; + import Loading from 'app/components/loading'; -import CustomList from 'app/components/custom_list'; +import CustomList, {FLATLIST, SECTIONLIST} from 'app/components/custom_list'; import UserListRow from 'app/components/custom_list/user_list_row'; +import FormattedText from 'app/components/formatted_text'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import {alertErrorIfInvalidPermissions} from 'app/utils/general'; -import {createMembersSections, loadingText, markSelectedProfiles} from 'app/utils/member_list'; -import {changeOpacity, setNavigatorStyles} from 'app/utils/theme'; +import {createProfilesSections, loadingText} from 'app/utils/member_list'; +import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; -import {General, RequestStatus} from 'mattermost-redux/constants'; -import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; - -class ChannelAddMembers extends PureComponent { +export default class ChannelAddMembers extends PureComponent { static propTypes = { - intl: intlShape.isRequired, - theme: PropTypes.object.isRequired, - currentChannel: PropTypes.object, - membersNotInChannel: PropTypes.array.isRequired, - currentTeam: PropTypes.object, - navigator: PropTypes.object, - preferences: PropTypes.object, - loadMoreRequestStatus: PropTypes.string, - searchRequestStatus: PropTypes.string, - addChannelMemberStatus: PropTypes.string, actions: PropTypes.shape({ getTeamStats: PropTypes.func.isRequired, getProfilesNotInChannel: PropTypes.func.isRequired, handleAddChannelMembers: PropTypes.func.isRequired, searchProfiles: PropTypes.func.isRequired, - }), + }).isRequired, + currentChannelId: PropTypes.string.isRequired, + currentTeamId: PropTypes.string.isRequired, + currentUserId: PropTypes.string.isRequired, + membersNotInChannel: PropTypes.array.isRequired, + navigator: PropTypes.object, + theme: PropTypes.object.isRequired, }; - addButton = { - disabled: true, - id: 'add-members', - showAsAction: 'always', + static contextTypes = { + intl: intlShape.isRequired, }; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.searchTimeoutId = 0; + this.page = -1; + this.next = true; this.state = { - canSelect: true, - next: true, - page: 0, + adding: false, + loading: false, profiles: [], - searching: false, - selectedMembers: {}, - showNoResults: false, + searchResults: [], + selectedIds: {}, term: '', - isLoading: true, }; - this.addButton.title = props.intl.formatMessage({id: 'integrations.add', defaultMessage: 'Add'}); + + this.addButton = { + disabled: true, + id: 'add-members', + title: context.intl.formatMessage({id: 'integrations.add', defaultMessage: 'Add'}), + showAsAction: 'always', + }; props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); props.navigator.setButtons({ @@ -75,136 +75,113 @@ class ChannelAddMembers extends PureComponent { } componentDidMount() { - InteractionManager.runAfterInteractions(() => { - this.props.actions.getProfilesNotInChannel(this.props.currentTeam.id, this.props.currentChannel.id, 0); - this.props.actions.getTeamStats(this.props.currentTeam.id); - }); + const {actions, currentTeamId} = this.props; - this.emitCanAddMembers(false); + actions.getTeamStats(currentTeamId); + this.getProfiles(); + + this.enableAddOption(false); } - componentWillReceiveProps(nextProps) { - if (this.props.theme !== nextProps.theme) { - setNavigatorStyles(this.props.navigator, nextProps.theme); - } + componentDidUpdate(prevProps) { + const {navigator, theme} = this.props; + const {adding, selectedIds} = this.state; + const enabled = Object.keys(selectedIds).length > 0 && !adding; - const {loadMoreRequestStatus} = this.props; - if (loadMoreRequestStatus === RequestStatus.STARTED && - nextProps.loadMoreRequestStatus === RequestStatus.SUCCESS) { - const {page} = this.state; - const profiles = markSelectedProfiles( - nextProps.membersNotInChannel.slice(0, (page + 1) * General.PROFILE_CHUNK_SIZE), - this.state.selectedMembers - ); - this.setState({profiles, showNoResults: true}); - } else if (this.state.searching && - nextProps.searchRequestStatus === RequestStatus.SUCCESS) { - const results = markSelectedProfiles( - filterProfilesMatchingTerm(nextProps.membersNotInChannel, this.state.term), - this.state.selectedMembers - ); - this.setState({profiles: results, showNoResults: true}); - } + this.enableAddOption(enabled); - const {addChannelMemberStatus} = nextProps; - - if (this.props.addChannelMemberStatus !== addChannelMemberStatus) { - switch (addChannelMemberStatus) { - case RequestStatus.STARTED: - this.emitAdding(true); - this.setState({error: null, adding: true, canSelect: false}); - break; - case RequestStatus.SUCCESS: - this.emitAdding(false); - this.setState({error: null, adding: false, canSelect: false}); - this.close(); - break; - case RequestStatus.FAILURE: - this.emitAdding(false); - this.setState({adding: false, canSelect: true}); - break; - } - } - - if ((loadMoreRequestStatus !== nextProps.loadMoreRequestStatus) || (this.props.searchRequestStatus !== nextProps.searchRequestStatus)) { - const isLoading = (nextProps.loadMoreRequestStatus === RequestStatus.STARTED) || - (nextProps.searchRequestStatus === RequestStatus.STARTED); - this.setState({isLoading}); + if (theme !== prevProps.theme) { + setNavigatorStyles(navigator, theme); } } - cancelSearch = () => { - this.setState({ - searching: false, - term: '', - page: 0, - profiles: markSelectedProfiles(this.props.membersNotInChannel, this.state.selectedMembers), - }); + clearSearch = () => { + this.setState({term: '', searchResults: []}); }; close = () => { this.props.navigator.pop({animated: true}); }; - emitAdding = (loading) => { - this.props.navigator.setButtons({ - rightButtons: [{...this.addButton, disabled: loading}], - }); - }; - - emitCanAddMembers = (enabled) => { + enableAddOption = (enabled) => { this.props.navigator.setButtons({ rightButtons: [{...this.addButton, disabled: !enabled}], }); }; - handleAddMembersPress = async () => { - const {selectedMembers} = this.state; - const {actions, currentChannel} = this.props; - const membersToAdd = Object.keys(selectedMembers).filter((m) => selectedMembers[m]); + getProfiles = debounce(() => { + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + const {actions, currentChannelId, currentTeamId} = this.props; + + actions.getProfilesNotInChannel( + currentTeamId, + currentChannelId, + this.page + 1, + General.PROFILE_CHUNK_SIZE + ).then(this.loadedProfiles); + }); + } + }, 100); + + handleAddMembersPress = () => { + const {formatMessage} = this.context.intl; + const {actions, currentChannelId} = this.props; + const {selectedIds, adding} = this.state; + const membersToAdd = Object.keys(selectedIds).filter((id) => selectedIds[id]); if (!membersToAdd.length) { - Alert.alert('Add Members', 'You must select at least one member to add to the channel.'); + Alert.alert( + formatMessage({id: 'channel_header.addMembers', defaultMessage: 'Add Members'}), + formatMessage({ + id: 'mobile.channel_members.add_members_alert', + defaultMessage: 'You must select at least one member to add to the channel.', + }) + ); + return; } - alertErrorIfInvalidPermissions( - await actions.handleAddChannelMembers(currentChannel.id, membersToAdd) - ); - }; + if (!adding) { + this.enableAddOption(false); + this.setState({adding: true}, async () => { + const result = await actions.handleAddChannelMembers(currentChannelId, membersToAdd); - handleRowSelect = (id) => { - const selectedMembers = Object.assign({}, this.state.selectedMembers, {[id]: !this.state.selectedMembers[id]}); - - if (Object.values(selectedMembers).filter((selected) => selected).length) { - this.emitCanAddMembers(true); - } else { - this.emitCanAddMembers(false); - } - this.setState({ - profiles: markSelectedProfiles(this.state.profiles, selectedMembers), - selectedMembers, - }); - }; - - loadMoreMembers = () => { - const {actions, loadMoreRequestStatus, currentChannel, currentTeam} = this.props; - const {next, searching} = this.state; - let {page} = this.state; - if (loadMoreRequestStatus !== RequestStatus.STARTED && next && !searching) { - page += 1; - actions.getProfilesNotInChannel(currentTeam.id, currentChannel.id, page, General.PROFILE_CHUNK_SIZE).then(({data}) => { - if (data && data.length) { - this.setState({ - page, - }); + if (result.error) { + alertErrorIfInvalidPermissions(result); + this.enableAddOption(true); + this.setState({adding: false}); } else { - this.setState({next: false}); + this.close(); } }); } }; + handleSelectProfile = (id) => { + const {selectedIds} = this.state; + const newSelected = Object.assign({}, selectedIds, {[id]: !selectedIds[id]}); + + if (Object.values(newSelected).filter((selected) => selected).length) { + this.enableAddOption(true); + } else { + this.enableAddOption(false); + } + + this.setState({selectedIds: newSelected}); + }; + + loadedProfiles = ({data}) => { + const {profiles} = this.state; + if (data && !data.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, profiles: [...profiles, ...data]}); + }; + onNavigatorEvent = (event) => { if (event.type === 'NavBarButtonPress') { if (event.id === this.addButton.id) { @@ -213,34 +190,104 @@ class ChannelAddMembers extends PureComponent { } }; - searchProfiles = (text) => { - const term = text; - const {actions, currentChannel, currentTeam} = this.props; + onSearch = (text) => { + const term = text.toLowerCase(); if (term) { - this.setState({searching: true, term}); + this.setState({term}); clearTimeout(this.searchTimeoutId); this.searchTimeoutId = setTimeout(() => { - actions.searchProfiles(term.toLowerCase(), {not_in_channel_id: currentChannel.id, team_id: currentTeam.id}); + this.searchProfiles(term); }, General.SEARCH_TIMEOUT_MILLISECONDS); } else { - this.cancelSearch(); + this.clearSearch(); } }; + renderItem = (props) => { + // The list will re-render when the selection changes because it's passed into the list as extraData + const selected = this.state.selectedIds[props.id]; + + return ( + + ); + }; + + renderLoading = () => { + const {theme} = this.props; + const {loading} = this.state; + const style = getStyleFromTheme(theme); + + if (!loading) { + return null; + } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading || this.page === -1) { + return null; + } + + return ( + + + + ); + }; + + searchProfiles = (term) => { + const {actions, currentChannelId, currentTeamId} = this.props; + const options = {not_in_channel_id: currentChannelId, team_id: currentTeamId}; + this.setState({loading: true}); + + actions.searchProfiles(term, options).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + }; + render() { - const {intl, preferences, theme} = this.props; - const {adding, profiles, searching, term} = this.state; - const {formatMessage} = intl; - const more = searching ? () => true : this.loadMoreMembers; + const {formatMessage} = this.context.intl; + const {currentUserId, theme} = this.props; + const { + adding, + loading, + profiles, + searchResults, + selectedIds, + term, + } = this.state; + const style = getStyleFromTheme(theme); if (adding) { return ( - + - + ); } @@ -255,12 +302,33 @@ class ChannelAddMembers extends PureComponent { }), }; + let data; + let listType; + if (term) { + const exactMatches = []; + const results = filterProfilesMatchingTerm(searchResults, term).filter((p) => { + if (p.id === currentUserId) { + return false; + } + + if (p.username === term || p.username.startsWith(term)) { + exactMatches.push(p); + return false; + } + + return true; + }); + data = [...exactMatches, ...results]; + listType = FLATLIST; + } else { + data = createProfilesSections(profiles); + listType = SECTIONLIST; + } + return ( - + ); } } -export default injectIntl(ChannelAddMembers); +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginVertical: 5, + }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); diff --git a/app/screens/channel_add_members/index.js b/app/screens/channel_add_members/index.js index 3dda9f28f..2708e8794 100644 --- a/app/screens/channel_add_members/index.js +++ b/app/screens/channel_add_members/index.js @@ -4,27 +4,24 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {handleAddChannelMembers} from 'app/actions/views/channel_add_members'; -import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getMyPreferences, getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; -import {getProfilesNotInCurrentChannel} from 'mattermost-redux/selectors/entities/users'; import {getTeamStats} from 'mattermost-redux/actions/teams'; import {getProfilesNotInChannel, searchProfiles} from 'mattermost-redux/actions/users'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUserId, getProfilesNotInCurrentChannel} from 'mattermost-redux/selectors/entities/users'; + +import {handleAddChannelMembers} from 'app/actions/views/channel_add_members'; import ChannelAddMembers from './channel_add_members'; function mapStateToProps(state) { return { - theme: getTheme(state), - currentChannel: getCurrentChannel(state) || {}, + currentChannelId: getCurrentChannelId(state), + currentTeamId: getCurrentTeamId(state), + currentUserId: getCurrentUserId(state), membersNotInChannel: getProfilesNotInCurrentChannel(state), - currentTeam: getCurrentTeam(state) || {}, - preferences: getMyPreferences(state), - loadMoreRequestStatus: state.requests.users.getProfilesNotInChannel.status, - addChannelMemberRequestStatus: state.requests.channels.addChannelMember, - searchRequestStatus: state.requests.users.searchProfiles.status, - addChannelMemberStatus: state.requests.channels.addChannelMember.status, + theme: getTheme(state), }; } diff --git a/app/screens/channel_members/channel_members.js b/app/screens/channel_members/channel_members.js index b63a9c3ab..e897fae18 100644 --- a/app/screens/channel_members/channel_members.js +++ b/app/screens/channel_members/channel_members.js @@ -5,66 +5,67 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { Alert, - InteractionManager, Platform, View, } from 'react-native'; -import {injectIntl, intlShape} from 'react-intl'; +import {intlShape} from 'react-intl'; + +import {debounce} from 'mattermost-redux/actions/helpers'; +import {General} from 'mattermost-redux/constants'; +import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; import Loading from 'app/components/loading'; -import CustomList from 'app/components/custom_list'; +import CustomList, {FLATLIST, SECTIONLIST} from 'app/components/custom_list'; +import UserListRow from 'app/components/custom_list/user_list_row'; +import FormattedText from 'app/components/formatted_text'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import {alertErrorIfInvalidPermissions} from 'app/utils/general'; -import {createMembersSections, loadingText, markSelectedProfiles} from 'app/utils/member_list'; -import UserListRow from 'app/components/custom_list/user_list_row'; -import {changeOpacity, setNavigatorStyles} from 'app/utils/theme'; +import {createProfilesSections, loadingText} from 'app/utils/member_list'; +import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; -import {General, RequestStatus} from 'mattermost-redux/constants'; -import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; - -class ChannelMembers extends PureComponent { +export default class ChannelMembers extends PureComponent { static propTypes = { - intl: intlShape.isRequired, - theme: PropTypes.object.isRequired, - currentChannel: PropTypes.object, - currentChannelMembers: PropTypes.array, - currentUserId: PropTypes.string.isRequired, - navigator: PropTypes.object, - requestStatus: PropTypes.string, - searchRequestStatus: PropTypes.string, - removeMembersStatus: PropTypes.string, - canManageUsers: PropTypes.bool.isRequired, actions: PropTypes.shape({ getProfilesInChannel: PropTypes.func.isRequired, handleRemoveChannelMembers: PropTypes.func.isRequired, searchProfiles: PropTypes.func.isRequired, - }), + }).isRequired, + canManageUsers: PropTypes.bool.isRequired, + currentChannelId: PropTypes.string.isRequired, + currentChannelMembers: PropTypes.array, + currentUserId: PropTypes.string.isRequired, + navigator: PropTypes.object, + theme: PropTypes.object.isRequired, }; - removeButton = { - disabled: true, - id: 'remove-members', - showAsAction: 'always', + static contextTypes = { + intl: intlShape.isRequired, }; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.searchTimeoutId = 0; + this.page = -1; + this.next = true; this.state = { - canSelect: true, - next: true, - page: 0, + loading: false, profiles: [], - searching: false, - selectedMembers: {}, - showNoResults: false, + removing: false, + searchResults: [], + selectedIds: {}, term: '', }; - this.removeButton.title = props.intl.formatMessage({id: 'channel_members_modal.remove', defaultMessage: 'Remove'}); + + this.removeButton = { + disabled: true, + id: 'remove-members', + showAsAction: 'always', + title: context.intl.formatMessage({id: 'channel_members_modal.remove', defaultMessage: 'Remove'}), + }; props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); if (props.canManageUsers) { @@ -75,71 +76,30 @@ class ChannelMembers extends PureComponent { } componentDidMount() { - InteractionManager.runAfterInteractions(() => { - this.props.actions.getProfilesInChannel(this.props.currentChannel.id, 0); - }); - - this.emitCanRemoveMembers(false); + this.getProfiles(); } - componentWillReceiveProps(nextProps) { - if (this.props.theme !== nextProps.theme) { - setNavigatorStyles(this.props.navigator, nextProps.theme); - } + componentDidUpdate(prevProps) { + const {navigator, theme} = this.props; + const {removing, selectedIds} = this.state; + const enabled = Object.keys(selectedIds).length > 0 && !removing; - const {requestStatus} = this.props; - if (requestStatus === RequestStatus.STARTED && - nextProps.requestStatus === RequestStatus.SUCCESS) { - const {page} = this.state; - const profiles = markSelectedProfiles( - nextProps.currentChannelMembers.slice(0, (page + 1) * General.PROFILE_CHUNK_SIZE), - this.state.selectedMembers - ); - this.setState({profiles, showNoResults: true}); - } else if (this.state.searching && - nextProps.searchRequestStatus === RequestStatus.SUCCESS) { - const results = markSelectedProfiles( - filterProfilesMatchingTerm(nextProps.currentChannelMembers, this.state.term), - this.state.selectedMembers - ); - this.setState({profiles: results, showNoResults: true}); - } + this.enableRemoveOption(enabled); - const {removeMembersStatus} = nextProps; - - if (this.props.removeMembersStatus !== removeMembersStatus) { - switch (removeMembersStatus) { - case RequestStatus.STARTED: - this.emitRemoving(true); - this.setState({error: null, canSelect: false, removing: true}); - break; - case RequestStatus.SUCCESS: - this.emitRemoving(false); - this.setState({error: null, canSelect: false, removing: false}); - this.close(); - break; - case RequestStatus.FAILURE: - this.emitRemoving(false); - this.setState({canSelect: true, removing: false}); - break; - } + if (theme !== prevProps.theme) { + setNavigatorStyles(navigator, theme); } } - cancelSearch = () => { - this.setState({ - searching: false, - term: '', - page: 0, - profiles: markSelectedProfiles(this.props.currentChannelMembers, this.state.selectedMembers), - }); + clearSearch = () => { + this.setState({term: '', searchResults: []}); }; close = () => { this.props.navigator.pop({animated: true}); }; - emitCanRemoveMembers = (enabled) => { + enableRemoveOption = (enabled) => { if (this.props.canManageUsers) { this.props.navigator.setButtons({ rightButtons: [{...this.removeButton, disabled: !enabled}], @@ -147,21 +107,26 @@ class ChannelMembers extends PureComponent { } }; - emitRemoving = (loading) => { - this.setState({canSelect: false, removing: loading}); + getProfiles = debounce(() => { + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + const {actions, currentChannelId} = this.props; - if (this.props.canManageUsers) { - this.props.navigator.setButtons({ - rightButtons: [{...this.removeButton, disabled: loading}], + actions.getProfilesInChannel( + currentChannelId, + this.page + 1, + General.PROFILE_CHUNK_SIZE + ).then(this.loadedProfiles); }); } - }; + }, 100); handleRemoveMembersPress = () => { - const {selectedMembers} = this.state; - const membersToRemove = Object.keys(selectedMembers).filter((m) => selectedMembers[m]); + const {formatMessage} = this.context.intl; + const {selectedIds, removing} = this.state; + const membersToRemove = Object.keys(selectedIds).filter((id) => selectedIds[id]); - const {formatMessage} = this.props.intl; if (!membersToRemove.length) { Alert.alert( formatMessage({ @@ -176,113 +141,176 @@ class ChannelMembers extends PureComponent { return; } - Alert.alert( - formatMessage({ - id: 'mobile.routes.channel_members.action', - defaultMessage: 'Remove Members', - }), - formatMessage({ - id: 'mobile.routes.channel_members.action_message_confirm', - defaultMessage: 'Are you sure you want to remove the selected members from the channel?', - }), - [{ - text: formatMessage({id: 'mobile.channel_list.alertNo', defaultMessage: 'No'}), - }, { - text: formatMessage({id: 'mobile.channel_list.alertYes', defaultMessage: 'Yes'}), - onPress: () => this.removeMembers(membersToRemove), - }] - ); - }; - - handleRowSelect = (id) => { - const selectedMembers = Object.assign({}, this.state.selectedMembers, {[id]: !this.state.selectedMembers[id]}); - if (Object.values(selectedMembers).filter((selected) => selected).length) { - this.emitCanRemoveMembers(true); - } else { - this.emitCanRemoveMembers(false); - } - this.setState({ - profiles: markSelectedProfiles(this.state.profiles, selectedMembers), - selectedMembers, - }); - }; - - loadMoreMembers = () => { - const {actions, requestStatus, currentChannel} = this.props; - const {next, searching} = this.state; - let {page} = this.state; - if (requestStatus !== RequestStatus.STARTED && next && !searching) { - page += 1; - actions.getProfilesInChannel(currentChannel.id, page, General.PROFILE_CHUNK_SIZE).then( - ({data}) => { - if (data && data.length) { - this.setState({ - page, - }); - } else { - this.setState({next: false}); - } - } + if (!removing) { + Alert.alert( + formatMessage({ + id: 'mobile.routes.channel_members.action', + defaultMessage: 'Remove Members', + }), + formatMessage({ + id: 'mobile.routes.channel_members.action_message_confirm', + defaultMessage: 'Are you sure you want to remove the selected members from the channel?', + }), + [{ + text: formatMessage({id: 'mobile.channel_list.alertNo', defaultMessage: 'No'}), + }, { + text: formatMessage({id: 'mobile.channel_list.alertYes', defaultMessage: 'Yes'}), + onPress: () => this.removeMembers(membersToRemove), + }] ); } }; + handleSelectProfile = (id) => { + const {canManageUsers} = this.props; + const {selectedIds} = this.state; + + if (canManageUsers) { + const newSelected = Object.assign({}, selectedIds, {[id]: !selectedIds[id]}); + + if (Object.values(newSelected).filter((selected) => selected).length) { + this.enableRemoveOption(true); + } else { + this.enableRemoveOption(false); + } + + this.setState({selectedIds: newSelected}); + } + }; + + loadedProfiles = ({data}) => { + const {profiles} = this.state; + if (data && !data.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, profiles: [...profiles, ...data]}); + }; + onNavigatorEvent = (event) => { if (event.type === 'NavBarButtonPress') { - if (event.id === 'remove-members') { + if (event.id === this.removeButton.id) { this.handleRemoveMembersPress(); } } }; - removeMembers = async (membersToRemove) => { - const {actions, currentChannel} = this.props; - alertErrorIfInvalidPermissions( - await actions.handleRemoveChannelMembers(currentChannel.id, membersToRemove) - ); + onSearch = (text) => { + const term = text.toLowerCase(); + + if (term) { + this.setState({term}); + clearTimeout(this.searchTimeoutId); + + this.searchTimeoutId = setTimeout(() => { + this.searchProfiles(term); + }, General.SEARCH_TIMEOUT_MILLISECONDS); + } else { + this.clearSearch(); + } }; - renderMemberRow = (props) => { + removeMembers = async (membersToRemove) => { + const {actions, currentChannelId} = this.props; + this.enableRemoveOption(false); + this.setState({adding: true}, async () => { + const result = await actions.handleRemoveChannelMembers(currentChannelId, membersToRemove); + + if (result.error) { + alertErrorIfInvalidPermissions(result); + this.enableRemoveOption(true); + this.setState({removing: false}); + } else { + this.close(); + } + }); + }; + + renderItem = (props) => { + // The list will re-render when the selection changes because it's passed into the list as extraData + const selected = this.state.selectedIds[props.id]; const enabled = props.id !== this.props.currentUserId; return ( ); }; - searchProfiles = (text) => { - const term = text; + renderLoading = () => { + const {theme} = this.props; + const {loading} = this.state; + const style = getStyleFromTheme(theme); - if (term) { - this.setState({searching: true, term}); - clearTimeout(this.searchTimeoutId); - - this.searchTimeoutId = setTimeout(() => { - this.props.actions.searchProfiles(term.toLowerCase(), {in_channel_id: this.props.currentChannel.id}); - }, General.SEARCH_TIMEOUT_MILLISECONDS); - } else { - this.cancelSearch(); + if (!loading) { + return null; } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading || this.page === -1) { + return null; + } + + return ( + + + + ); + }; + + searchProfiles = (term) => { + const {actions, currentChannelId} = this.props; + const options = {in_channel_id: currentChannelId}; + this.setState({loading: true}); + + actions.searchProfiles(term, options).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); }; render() { - const {canManageUsers, intl, requestStatus, searchRequestStatus, theme} = this.props; - const {formatMessage} = intl; - const {profiles, removing, searching, showNoResults, term} = this.state; - const isLoading = (requestStatus === RequestStatus.STARTED) || (requestStatus.status === RequestStatus.NOT_STARTED) || - (searchRequestStatus === RequestStatus.STARTED); - const more = searching ? () => true : this.loadMoreMembers; + const {formatMessage} = this.context.intl; + const {theme} = this.props; + const { + removing, + loading, + profiles, + searchResults, + selectedIds, + term, + } = this.state; + const style = getStyleFromTheme(theme); if (removing) { return ( - + - + ); } @@ -297,12 +325,29 @@ class ChannelMembers extends PureComponent { }), }; + let data; + let listType; + if (term) { + const exactMatches = []; + const results = filterProfilesMatchingTerm(searchResults, term).filter((p) => { + if (p.username === term || p.username.startsWith(term)) { + exactMatches.push(p); + return false; + } + + return true; + }); + data = [...exactMatches, ...results]; + listType = FLATLIST; + } else { + data = createProfilesSections(profiles); + listType = SECTIONLIST; + } + return ( - + ); } } -export default injectIntl(ChannelMembers); +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginVertical: 5, + }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); diff --git a/app/screens/channel_members/index.js b/app/screens/channel_members/index.js index 564161070..64bdef801 100644 --- a/app/screens/channel_members/index.js +++ b/app/screens/channel_members/index.js @@ -6,7 +6,7 @@ import {connect} from 'react-redux'; import {handleRemoveChannelMembers} from 'app/actions/views/channel_members'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentChannel, canManageChannelMembers} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannelId, canManageChannelMembers} from 'mattermost-redux/selectors/entities/channels'; import {makeGetProfilesInChannel} from 'mattermost-redux/selectors/entities/users'; import {getProfilesInChannel, searchProfiles} from 'mattermost-redux/actions/users'; @@ -16,21 +16,21 @@ function makeMapStateToProps() { const getChannelMembers = makeGetProfilesInChannel(); return (state) => { - const currentChannel = getCurrentChannel(state) || {}; + const currentChannelId = getCurrentChannelId(state); let currentChannelMembers = []; - if (currentChannel) { - currentChannelMembers = getChannelMembers(state, currentChannel.id, true); + if (currentChannelId) { + currentChannelMembers = getChannelMembers(state, currentChannelId, true); } return { - theme: getTheme(state), - currentChannel, + canManageUsers: canManageChannelMembers(state), + currentChannelId, currentChannelMembers, currentUserId: state.entities.users.currentUserId, requestStatus: state.requests.users.getProfilesInChannel.status, searchRequestStatus: state.requests.users.searchProfiles.status, removeMembersStatus: state.requests.channels.removeChannelMember.status, - canManageUsers: canManageChannelMembers(state), + theme: getTheme(state), }; }; } diff --git a/app/screens/create_channel/create_channel.js b/app/screens/create_channel/create_channel.js index 4433bf82d..14cc18317 100644 --- a/app/screens/create_channel/create_channel.js +++ b/app/screens/create_channel/create_channel.js @@ -110,7 +110,6 @@ export default class CreateChannel extends PureComponent { } close = (goBack = false) => { - EventEmitter.emit('closing-create-channel', false); if (goBack) { this.props.navigator.pop({animated: true}); } else { diff --git a/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap b/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap index f13d40805..f695b1e5d 100644 --- a/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap +++ b/app/screens/menu_action_selector/__snapshots__/menu_action_selector.test.js.snap @@ -1,525 +1,524 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MenuActionSelector should match snapshot for channels 1`] = ` - + + + + + + /> + `; exports[`MenuActionSelector should match snapshot for channels 2`] = ` - + + + + + + /> + `; exports[`MenuActionSelector should match snapshot for explicit options 1`] = ` - + + + + + + /> + `; exports[`MenuActionSelector should match snapshot for searching 1`] = ` - + + + + + + /> + `; exports[`MenuActionSelector should match snapshot for users 1`] = ` - + + + + + + /> + `; exports[`MenuActionSelector should match snapshot for users 2`] = ` - + + + + + + /> + `; diff --git a/app/screens/menu_action_selector/index.js b/app/screens/menu_action_selector/index.js index d4f08a3b4..bc6713709 100644 --- a/app/screens/menu_action_selector/index.js +++ b/app/screens/menu_action_selector/index.js @@ -4,50 +4,31 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {getChannelsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getProfiles} from 'mattermost-redux/selectors/entities/users'; -import {getProfiles as getProfilesAction, searchProfiles} from 'mattermost-redux/actions/users'; +import {getProfiles, searchProfiles} from 'mattermost-redux/actions/users'; import {getChannels, searchChannels} from 'mattermost-redux/actions/channels'; -import {ViewTypes} from 'app/constants'; - import MenuActionSelector from './menu_action_selector'; function mapStateToProps(state) { const menuAction = state.views.post.selectedMenuAction || {}; - let data; - let loadMoreRequestStatus; - let searchRequestStatus; - if (menuAction.dataSource === ViewTypes.DATA_SOURCE_USERS) { - data = getProfiles(state); - loadMoreRequestStatus = state.requests.users.getProfiles.status; - searchRequestStatus = state.requests.users.searchProfiles.status; - } else if (menuAction.dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { - data = getChannelsInCurrentTeam(state); - loadMoreRequestStatus = state.requests.channels.getChannels.status; - searchRequestStatus = state.requests.channels.getChannels.status; - } else { - data = menuAction.options || []; - } + const data = menuAction.options || []; return { + currentTeamId: getCurrentTeamId(state), data, dataSource: menuAction.dataSource, onSelect: menuAction.onSelect, theme: getTheme(state), - currentTeamId: getCurrentTeamId(state), - loadMoreRequestStatus, - searchRequestStatus, }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - getProfiles: getProfilesAction, + getProfiles, getChannels, searchProfiles, searchChannels, diff --git a/app/screens/menu_action_selector/menu_action_selector.js b/app/screens/menu_action_selector/menu_action_selector.js index 28b2b583f..325fbe611 100644 --- a/app/screens/menu_action_selector/menu_action_selector.js +++ b/app/screens/menu_action_selector/menu_action_selector.js @@ -3,82 +3,80 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; +import {intlShape} from 'react-intl'; import { - InteractionManager, Platform, View, } from 'react-native'; -import CustomList from 'app/components/custom_list'; -import UserListRow from 'app/components/custom_list/user_list_row'; -import ChannelListRow from 'app/components/custom_list/channel_list_row'; -import OptionListRow from 'app/components/custom_list/option_list_row'; -import SearchBar from 'app/components/search_bar'; -import StatusBar from 'app/components/status_bar'; -import {loadingText} from 'app/utils/member_list'; -import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; -import {ViewTypes} from 'app/constants'; - -import {General, RequestStatus} from 'mattermost-redux/constants'; +import {debounce} from 'mattermost-redux/actions/helpers'; +import {General} from 'mattermost-redux/constants'; import {filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; import {filterChannelsMatchingTerm} from 'mattermost-redux/utils/channel_utils'; import {memoizeResult} from 'mattermost-redux/utils/helpers'; -class MenuActionSelector extends PureComponent { +import CustomList, {FLATLIST, SECTIONLIST} from 'app/components/custom_list'; +import UserListRow from 'app/components/custom_list/user_list_row'; +import ChannelListRow from 'app/components/custom_list/channel_list_row'; +import OptionListRow from 'app/components/custom_list/option_list_row'; +import FormattedText from 'app/components/formatted_text'; +import SearchBar from 'app/components/search_bar'; +import StatusBar from 'app/components/status_bar'; +import {ViewTypes} from 'app/constants'; +import {createProfilesSections, loadingText} from 'app/utils/member_list'; +import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; +import {t} from 'app/utils/i18n'; + +export default class MenuActionSelector extends PureComponent { static propTypes = { - intl: intlShape.isRequired, - theme: PropTypes.object.isRequired, - navigator: PropTypes.object, - data: PropTypes.arrayOf(PropTypes.object), - dataSource: PropTypes.string, - onSelect: PropTypes.func.isRequired, - currentTeamId: PropTypes.string.isRequired, - loadMoreRequestStatus: PropTypes.string, - searchRequestStatus: PropTypes.string, actions: PropTypes.shape({ getProfiles: PropTypes.func.isRequired, getChannels: PropTypes.func.isRequired, searchProfiles: PropTypes.func.isRequired, searchChannels: PropTypes.func.isRequired, }), + currentTeamId: PropTypes.string.isRequired, + data: PropTypes.arrayOf(PropTypes.object), + dataSource: PropTypes.string, + navigator: PropTypes.object, + onSelect: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape.isRequired, }; constructor(props) { super(props); this.searchTimeoutId = 0; + this.page = -1; + this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS; let data = []; if (!props.dataSource) { data = props.data; } - const needsLoading = props.dataSource === ViewTypes.DATA_SOURCE_USERS || props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS; - this.state = { - next: needsLoading, - page: 0, data, - searching: false, - showNoResults: false, + loading: false, + searchResults: [], term: '', - isLoading: needsLoading, - prevLoadMoreRequestStatus: RequestStatus.STARTED, }; props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); } componentDidMount() { + const {dataSource} = this.props; this.mounted = true; - InteractionManager.runAfterInteractions(() => { - if (this.props.dataSource === ViewTypes.DATA_SOURCE_USERS) { - this.props.actions.getProfiles().then(() => this.setState({isLoading: false})); - } else if (this.props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { - this.props.actions.getChannels(this.props.currentTeamId).then(() => this.setState({isLoading: false})); - } - }); + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.getProfiles(); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.getChannels(); + } } componentWillUnmount() { @@ -91,97 +89,199 @@ class MenuActionSelector extends PureComponent { } } - cancelSearch = () => { - this.setState({ - searching: false, - isLoading: false, - term: '', - page: 0, - data: filterPageData(this.props.data, 0), - }); + clearSearch = () => { + this.setState({term: '', searchResults: []}); }; close = () => { this.props.navigator.pop({animated: true}); }; - handleRowSelect = (id, selected) => { - this.props.onSelect(selected); + handleSelectItem = (id, item) => { + this.props.onSelect(item); this.close(); }; - loadMore = async () => { - const {actions, loadMoreRequestStatus, currentTeamId, dataSource} = this.props; - const {next, searching} = this.state; - let {page} = this.state; + getChannels = debounce(() => { + const {actions, currentTeamId} = this.props; + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + actions.getChannels( + currentTeamId, + this.page += 1, + General.CHANNELS_CHUNK_SIZE + ).then(this.loadedChannels); + }); + } + }, 100); - if (loadMoreRequestStatus !== RequestStatus.STARTED && next && !searching) { - page += 1; + getDataResults = () => { + const {dataSource} = this.props; + const {data, searchResults, term} = this.state; - let results; - if (dataSource === ViewTypes.DATA_SOURCE_USERS) { - results = await actions.getProfiles(page, General.PROFILE_CHUNK_SIZE); - } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { - results = await actions.getChannels(currentTeamId, page, General.PROFILE_CHUNK_SIZE); - } else { - return; - } + const result = { + data, + listType: FLATLIST}; + if (term) { + result.data = filterSearchData(dataSource, searchResults, term); + } else if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + result.data = createProfilesSections(data); + result.listType = SECTIONLIST; + } - if (!this.mounted) { - return; - } + return result; + }; - if (results.data && results.data.length) { - this.setState({ - isLoading: false, - page, - }); - } else { - this.setState({ - isLoading: false, - next: false, - }); - } + getProfiles = debounce(() => { + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + const {actions} = this.props; + + actions.getProfiles( + this.page + 1, + General.PROFILE_CHUNK_SIZE + ).then(this.loadedProfiles); + }); + } + }, 100); + + loadedChannels = ({data: channels}) => { + const {data} = this.state; + if (channels && !channels.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, data: [...channels, ...data]}); + }; + + loadedProfiles = ({data: profiles}) => { + const {data} = this.state; + if (profiles && !profiles.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, data: [...profiles, ...data]}); + }; + + loadMore = () => { + const {dataSource} = this.props; + + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.getProfiles(); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.getChannels(); } }; - searchProfiles = (text) => { - const term = text; - const {actions, currentTeamId, dataSource} = this.props; + onSearch = (text) => { + const term = text.toLowerCase(); if (term) { - if (!dataSource) { - this.setState({data: filterSearchData(null, this.props.data, term.toLowerCase())}); - return; - } - - this.setState({searching: true, isLoading: true, term}); + const {dataSource, data} = this.props; + this.setState({term}); clearTimeout(this.searchTimeoutId); - this.searchTimeoutId = setTimeout(async () => { - if (dataSource === ViewTypes.DATA_SOURCE_USERS) { - await actions.searchProfiles(term.toLowerCase()); - } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { - await actions.searchChannels(currentTeamId, term.toLowerCase()); - } - - if (!this.mounted) { + this.searchTimeoutId = setTimeout(() => { + if (!dataSource) { + this.setState({searchResults: filterSearchData(null, data, term)}); return; } - this.setState({isLoading: false}); + if (dataSource === ViewTypes.DATA_SOURCE_USERS) { + this.searchProfiles(term); + } else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) { + this.searchChannels(term); + } }, General.SEARCH_TIMEOUT_MILLISECONDS); } else { - this.cancelSearch(); + this.clearSearch(); } }; - render() { - const {intl, data, theme, dataSource} = this.props; - const {searching, term, page} = this.state; - const {formatMessage} = intl; + searchChannels = (term) => { + const {actions, currentTeamId} = this.props; + + actions.searchChannels(currentTeamId, term).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + }; + + searchProfiles = (term) => { + const {actions} = this.props; + this.setState({loading: true}); + + actions.searchProfiles(term).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + }; + + renderLoading = () => { + const {dataSource, theme} = this.props; + const {loading} = this.state; + const style = getStyleFromTheme(theme); + + if (!loading) { + return null; + } + + let text; + switch (dataSource) { + case ViewTypes.DATA_SOURCE_USERS: + text = loadingText; + break; + case ViewTypes.DATA_SOURCE_CHANNELS: + text = { + id: t('mobile.loading_channels'), + defaultMessage: 'Loading Channels...', + }; + break; + default: + text = { + id: t('mobile.loading_options'), + defaultMessage: 'Loading Options...', + }; + break; + } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading || this.page === -1) { + return null; + } + + return ( + + + + ); + }; + + render() { + const {formatMessage} = this.context.intl; + const {theme, dataSource} = this.props; + const {loading, term} = this.state; const style = getStyleFromTheme(theme); - const more = searching ? () => true : this.loadMore; const searchBarInput = { backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), @@ -203,19 +303,12 @@ class MenuActionSelector extends PureComponent { rowComponent = OptionListRow; } - let filteredData; - if (searching) { - filteredData = filterSearchData(dataSource, data, term); - } else { - filteredData = filterPageData(data, page); - } + const {data, listType} = this.getDataResults(); return ( - + ); @@ -259,16 +350,31 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { flex: 1, backgroundColor: theme.centerChannelBg, }, - searchContainer: { + searchBar: { marginVertical: 5, }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, }; }); -const filterPageData = memoizeResult((data, page) => { - return data.slice(0, (page + 1) * General.PROFILE_CHUNK_SIZE); -}); - const filterSearchData = memoizeResult((dataSource, data, term) => { if (!data) { return []; @@ -282,5 +388,3 @@ const filterSearchData = memoizeResult((dataSource, data, term) => { return data.filter((option) => option.text && option.text.toLowerCase().startsWith(term)); }); - -export default injectIntl(MenuActionSelector); diff --git a/app/screens/more_channels/__snapshots__/more_channels.test.js.snap b/app/screens/more_channels/__snapshots__/more_channels.test.js.snap index 3be047f24..f1090561f 100644 --- a/app/screens/more_channels/__snapshots__/more_channels.test.js.snap +++ b/app/screens/more_channels/__snapshots__/more_channels.test.js.snap @@ -37,7 +37,6 @@ exports[`MoreChannels should match snapshot 1`] = ` /> + + } - onListEndReached={[Function]} - onListEndReachedThreshold={50} + noResults={ + + + + } + onLoadMore={[Function]} onRowPress={[Function]} - renderRow={[Function]} - searching={false} - selectable={false} - showNoResults={false} - showSections={false} + renderItem={[Function]} + showNoResults={true} theme={ Object { "awayIndicator": "#ffbc42", diff --git a/app/screens/more_channels/index.js b/app/screens/more_channels/index.js index 096e644cc..679744979 100644 --- a/app/screens/more_channels/index.js +++ b/app/screens/more_channels/index.js @@ -8,7 +8,8 @@ import {createSelector} from 'reselect'; import {General} from 'mattermost-redux/constants'; import {getChannels, joinChannel, searchChannels} from 'mattermost-redux/actions/channels'; import {getChannelsInCurrentTeam, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {showCreateOption} from 'mattermost-redux/utils/channel_utils'; import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils'; @@ -29,21 +30,18 @@ const joinableChannels = createSelector( ); function mapStateToProps(state) { - const {currentUserId} = state.entities.users; - const {currentTeamId} = state.entities.teams; - const {getChannels: requestStatus} = state.requests.channels; const config = getConfig(state); const license = getLicense(state); const roles = getCurrentUserRoles(state); const channels = joinableChannels(state); + const currentTeamId = getCurrentTeamId(state); return { canCreateChannels: showCreateOption(state, config, license, currentTeamId, General.OPEN_CHANNEL, isAdmin(roles), isSystemAdmin(roles)), - currentUserId, + currentUserId: getCurrentUserId(state), currentTeamId, channels, theme: getTheme(state), - requestStatus, }; } diff --git a/app/screens/more_channels/more_channels.js b/app/screens/more_channels/more_channels.js index 8a7e0f810..27e168d4f 100644 --- a/app/screens/more_channels/more_channels.js +++ b/app/screens/more_channels/more_channels.js @@ -4,36 +4,24 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; -import { - Platform, - InteractionManager, - StyleSheet, - View, -} from 'react-native'; +import {Platform, View} from 'react-native'; -import {General, RequestStatus} from 'mattermost-redux/constants'; +import {debounce} from 'mattermost-redux/actions/helpers'; +import {General} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import CustomList from 'app/components/custom_list'; import ChannelListRow from 'app/components/custom_list/channel_list_row'; +import FormattedText from 'app/components/formatted_text'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; import Loading from 'app/components/loading'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import {alertErrorWithFallback} from 'app/utils/general'; -import {changeOpacity, setNavigatorStyles} from 'app/utils/theme'; -import {t} from 'app/utils/i18n'; +import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; export default class MoreChannels extends PureComponent { static propTypes = { - currentUserId: PropTypes.string.isRequired, - currentTeamId: PropTypes.string.isRequired, - navigator: PropTypes.object, - theme: PropTypes.object.isRequired, - canCreateChannels: PropTypes.bool.isRequired, - channels: PropTypes.array, - closeButton: PropTypes.object, - requestStatus: PropTypes.object.isRequired, actions: PropTypes.shape({ handleSelectChannel: PropTypes.func.isRequired, joinChannel: PropTypes.func.isRequired, @@ -41,38 +29,43 @@ export default class MoreChannels extends PureComponent { searchChannels: PropTypes.func.isRequired, setChannelDisplayName: PropTypes.func.isRequired, }).isRequired, + canCreateChannels: PropTypes.bool.isRequired, + channels: PropTypes.array, + closeButton: PropTypes.object, + currentUserId: PropTypes.string.isRequired, + currentTeamId: PropTypes.string.isRequired, + navigator: PropTypes.object, + theme: PropTypes.object.isRequired, }; static contextTypes = { intl: intlShape.isRequired, }; - leftButton = { - id: 'close-more-channels', - }; - - rightButton = { - id: 'create-pub-channel', - showAsAction: 'always', - }; - constructor(props, context) { super(props, context); this.searchTimeoutId = 0; + this.page = -1; + this.next = true; this.state = { channels: props.channels.slice(0, General.CHANNELS_CHUNK_SIZE), - createScreenVisible: false, - page: 0, + loading: false, adding: false, - next: true, - searching: false, - showNoResults: false, term: '', }; - this.rightButton.title = context.intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}); - this.leftButton = {...this.leftButton, icon: props.closeButton}; + + this.rightButton = { + id: 'create-pub-channel', + title: context.intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}), + showAsAction: 'always', + }; + + this.leftButton = { + id: 'close-more-channels', + icon: props.closeButton, + }; const buttons = { leftButtons: [this.leftButton], @@ -86,136 +79,83 @@ export default class MoreChannels extends PureComponent { props.navigator.setButtons(buttons); } - componentWillMount() { - EventEmitter.on('closing-create-channel', this.handleCreateScreenVisible); - } - componentDidMount() { - // set the timeout to 400 cause is the time that the modal takes to open - // Somehow interactionManager doesn't care - setTimeout(() => { - this.props.actions.getChannels(this.props.currentTeamId, 0); - }, 400); + this.getChannels(); } componentWillReceiveProps(nextProps) { + const {term} = this.state; + let channels; + if (this.props.theme !== nextProps.theme) { setNavigatorStyles(this.props.navigator, nextProps.theme); } - const {page, searching, term} = this.state; - - let channels; if (nextProps.channels !== this.props.channels) { - channels = nextProps.channels.slice(0, (page + 1) * General.CHANNELS_CHUNK_SIZE); + channels = nextProps.channels.slice(0, (this.page + 1) * General.CHANNELS_CHUNK_SIZE); if (term) { channels = this.filterChannels(nextProps.channels, term); } } - const {requestStatus} = this.props; - if ( - searching && - nextProps.requestStatus.status === RequestStatus.SUCCESS - ) { - channels = this.filterChannels(nextProps.channels, term); - } else if ( - requestStatus.status === RequestStatus.STARTED && - nextProps.requestStatus.status === RequestStatus.SUCCESS - ) { - channels = nextProps.channels.slice(0, (page + 1) * General.CHANNELS_CHUNK_SIZE); - } - if (channels) { - this.setState({channels, showNoResults: true}); - } - - if (!this.state.createScreenVisible) { - this.headerButtons(nextProps.canCreateChannels, true); + this.setState({channels}); } } - componentWillUnmount() { - EventEmitter.off('closing-create-channel', this.handleCreateScreenVisible); - } + cancelSearch = () => { + const {channels} = this.props; + + this.setState({ + channels, + term: '', + }); + }; close = () => { this.props.navigator.dismissModal({animationType: 'slide-down'}); }; - emitCanCreateChannel = (enabled) => { - this.headerButtons(this.props.canCreateChannels, enabled); - }; - - handleCreateScreenVisible = (createScreenVisible) => { - this.setState({createScreenVisible}); - }; - - headerButtons = (canCreateChannels, enabled) => { - const buttons = { - leftButtons: [this.leftButton], - }; - - if (canCreateChannels) { - buttons.rightButtons = [{...this.rightButton, disabled: !enabled}]; - } - - this.props.navigator.setButtons(buttons); - }; - filterChannels = (channels, term) => { return channels.filter((c) => { return (c.name.toLowerCase().includes(term) || c.display_name.toLowerCase().includes(term)); }); }; - searchProfiles = (text) => { - const term = text.toLowerCase(); - - if (term) { - const channels = this.filterChannels(this.props.channels, term); - this.setState({ - channels, - term, - searching: true, - }); - clearTimeout(this.searchTimeoutId); - - this.searchTimeoutId = setTimeout(() => { - this.props.actions.searchChannels(this.props.currentTeamId, term); - }, General.SEARCH_TIMEOUT_MILLISECONDS); - } else { - this.cancelSearch(); - } - }; - - cancelSearch = () => { - this.props.actions.getChannels(this.props.currentTeamId, 0); - this.setState({ - term: '', - searching: false, - page: 0, - }); - }; - - loadMoreChannels = () => { - let {page} = this.state; - if (this.props.requestStatus.status !== RequestStatus.STARTED && this.state.next && !this.state.searching) { - page += 1; - this.props.actions.getChannels( - this.props.currentTeamId, - page, - General.CHANNELS_CHUNK_SIZE - ).then(({data}) => { - if (data && data.length) { - this.setState({ - page, - }); - } else { - this.setState({next: false}); - } + getChannels = debounce(() => { + const {actions, currentTeamId} = this.props; + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + actions.getChannels( + currentTeamId, + this.page + 1, + General.CHANNELS_CHUNK_SIZE + ).then(this.loadedChannels); }); } + }, 100); + + headerButtons = (createEnabled) => { + const {canCreateChannels} = this.props; + const buttons = { + leftButtons: [this.leftButton], + }; + + if (canCreateChannels) { + buttons.rightButtons = [{...this.rightButton, disabled: !createEnabled}]; + } + + this.props.navigator.setButtons(buttons); + }; + + loadedChannels = ({data}) => { + if (data && !data.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false}); }; onNavigatorEvent = (event) => { @@ -225,9 +165,7 @@ export default class MoreChannels extends PureComponent { this.close(); break; case 'create-pub-channel': - this.setState({ - createScreenVisible: true, - }, this.onCreateChannel); + this.onCreateChannel(); break; } } @@ -238,7 +176,7 @@ export default class MoreChannels extends PureComponent { const {actions, currentTeamId, currentUserId} = this.props; const {channels} = this.state; - this.emitCanCreateChannel(false); + this.headerButtons(false); this.setState({adding: true}); const channel = channels.find((c) => c.id === id); @@ -256,7 +194,7 @@ export default class MoreChannels extends PureComponent { displayName: channel ? channel.display_name : '', } ); - this.emitCanCreateChannel(true); + this.headerButtons(true); this.setState({adding: false}); } else { if (channel) { @@ -267,20 +205,20 @@ export default class MoreChannels extends PureComponent { await actions.handleSelectChannel(id); EventEmitter.emit('close_channel_drawer'); - InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { this.close(); }); } }; onCreateChannel = () => { - const {intl} = this.context; + const {formatMessage} = this.context.intl; const {navigator, theme} = this.props; navigator.push({ screen: 'CreateChannel', animationType: 'slide-up', - title: intl.formatMessage({id: 'mobile.create_channel.public', defaultMessage: 'New Public Channel'}), + title: formatMessage({id: 'mobile.create_channel.public', defaultMessage: 'New Public Channel'}), backButtonTitle: '', animated: true, navigatorStyle: { @@ -295,13 +233,83 @@ export default class MoreChannels extends PureComponent { }); }; + renderLoading = () => { + const {theme, channels} = this.props; + const style = getStyleFromTheme(theme); + + if (!channels.length && this.page <= 0) { + return null; + } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {term, loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading) { + return null; + } + + if (term) { + return ( + + + + ); + } + + return ( + + + + ); + }; + + searchChannels = (text) => { + const {actions, channels, currentTeamId} = this.props; + const term = text.toLowerCase(); + + if (term) { + const filtered = this.filterChannels(channels, term); + this.setState({ + channels: filtered, + term, + }); + clearTimeout(this.searchTimeoutId); + + this.searchTimeoutId = setTimeout(() => { + actions.searchChannels(currentTeamId, term); + }, General.SEARCH_TIMEOUT_MILLISECONDS); + } else { + this.cancelSearch(); + } + }; + render() { - const {intl} = this.context; - const {requestStatus, theme} = this.props; - const {adding, channels, searching, term} = this.state; - const {formatMessage} = intl; - const isLoading = requestStatus.status === RequestStatus.STARTED || requestStatus.status === RequestStatus.NOT_STARTED; - const more = searching ? () => true : this.loadMoreChannels; + const {formatMessage} = this.context.intl; + const {theme} = this.props; + const {adding, channels, loading, term} = this.state; + const more = term ? () => true : this.getChannels; + const style = getStyleFromTheme(theme); let content; if (adding) { @@ -320,7 +328,7 @@ export default class MoreChannels extends PureComponent { content = ( - + ); @@ -365,8 +372,29 @@ export default class MoreChannels extends PureComponent { } } -const style = StyleSheet.create({ - searchbar: { - marginVertical: 5, - }, +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + searchBar: { + marginVertical: 5, + }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; }); diff --git a/app/screens/more_channels/more_channels.test.js b/app/screens/more_channels/more_channels.test.js index 89f8e862f..51787ffcc 100644 --- a/app/screens/more_channels/more_channels.test.js +++ b/app/screens/more_channels/more_channels.test.js @@ -12,31 +12,29 @@ jest.mock('react-intl'); describe('MoreChannels', () => { const navigator = { - setOnNavigatorEvent: () => {}, // eslint-disable-line no-empty-function - setButtons: () => {}, // eslint-disable-line no-empty-function - dismissModal: () => {}, // eslint-disable-line no-empty-function - push: () => {}, // eslint-disable-line no-empty-function + setOnNavigatorEvent: jest.fn(), + setButtons: jest.fn(), + dismissModal: jest.fn(), + push: jest.fn(), }; const actions = { - handleSelectChannel: () => {}, // eslint-disable-line no-empty-function - joinChannel: () => {}, // eslint-disable-line no-empty-function - getChannels: () => {}, // eslint-disable-line no-empty-function - removeHiddenDefaultChannel: () => {}, // eslint-disable-line no-empty-function - searchChannels: () => {}, // eslint-disable-line no-empty-function - setChannelDisplayName: () => {}, // eslint-disable-line no-empty-function + handleSelectChannel: jest.fn(), + joinChannel: jest.fn(), + getChannels: jest.fn().mockResolvedValue({data: [{id: 'id2', name: 'name2', display_name: 'display_name2'}]}), + searchChannels: jest.fn(), + setChannelDisplayName: jest.fn(), }; const baseProps = { + actions, + canCreateChannels: true, + channels: [{id: 'id', name: 'name', display_name: 'display_name'}], + closeButton: {}, currentUserId: 'current_user_id', currentTeamId: 'current_team_id', navigator, theme: Preferences.THEMES.default, - canCreateChannels: true, - channels: [{id: 'id', name: 'name', display_name: 'display_name'}], - closeButton: {}, - requestStatus: {}, - actions, }; test('should match snapshot', () => { @@ -49,48 +47,14 @@ describe('MoreChannels', () => { }); test('should call props.navigator.dismissModal on close', () => { - const props = {...baseProps, navigator: {...navigator, dismissModal: jest.fn()}}; const wrapper = shallow( - , + , {context: {intl: {formatMessage: jest.fn()}}}, ); wrapper.instance().close(); - expect(props.navigator.dismissModal).toHaveBeenCalledTimes(1); - expect(props.navigator.dismissModal).toHaveBeenCalledWith({animationType: 'slide-down'}); - }); - - test('should call headerButtons on emitCanCreateChannel', () => { - const wrapper = shallow( - , - {context: {intl: {formatMessage: jest.fn()}}}, - ); - - const instance = wrapper.instance(); - instance.headerButtons = jest.fn(); - - wrapper.instance().emitCanCreateChannel(true); - expect(instance.headerButtons).toHaveBeenCalledTimes(1); - expect(instance.headerButtons).toHaveBeenCalledWith(baseProps.canCreateChannels, true); - - wrapper.instance().emitCanCreateChannel(false); - expect(instance.headerButtons).toHaveBeenCalledTimes(2); - expect(instance.headerButtons).toHaveBeenCalledWith(baseProps.canCreateChannels, false); - }); - - test('should match state on handleCreateScreenVisible', () => { - const wrapper = shallow( - , - {context: {intl: {formatMessage: jest.fn()}}}, - ); - - wrapper.setState({createScreenVisible: false}); - - wrapper.instance().handleCreateScreenVisible(true); - expect(wrapper.state('createScreenVisible')).toEqual(true); - - wrapper.instance().handleCreateScreenVisible(false); - expect(wrapper.state('createScreenVisible')).toEqual(false); + expect(baseProps.navigator.dismissModal).toHaveBeenCalledTimes(1); + expect(baseProps.navigator.dismissModal).toHaveBeenCalledWith({animationType: 'slide-down'}); }); test('should call props.navigator.setButtons on headerButtons', () => { @@ -101,7 +65,7 @@ describe('MoreChannels', () => { ); expect(props.navigator.setButtons).toHaveBeenCalledTimes(1); - wrapper.instance().headerButtons(); + wrapper.instance().headerButtons(true); expect(props.navigator.setButtons).toHaveBeenCalledTimes(2); }); @@ -119,18 +83,14 @@ describe('MoreChannels', () => { }); test('should match state on cancelSearch', () => { - const props = {...baseProps, actions: {...actions, getChannels: jest.fn()}}; const wrapper = shallow( - , + , {context: {intl: {formatMessage: jest.fn()}}}, ); - wrapper.setState({term: 'term', searching: true, page: 1}); + wrapper.setState({term: 'term'}); wrapper.instance().cancelSearch(true); - expect(props.actions.getChannels).toHaveBeenCalledTimes(1); - expect(props.actions.getChannels).toHaveBeenCalledWith(props.currentTeamId, 0); expect(wrapper.state('term')).toEqual(''); - expect(wrapper.state('searching')).toEqual(false); - expect(wrapper.state('page')).toEqual(0); + expect(wrapper.state('channels')).toEqual(baseProps.channels); }); }); diff --git a/app/screens/more_dms/index.js b/app/screens/more_dms/index.js index bca519d57..3427eaff8 100644 --- a/app/screens/more_dms/index.js +++ b/app/screens/more_dms/index.js @@ -3,7 +3,6 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {createSelector} from 'reselect'; import {setChannelDisplayName} from 'app/actions/views/channel'; import {makeDirectChannel, makeGroupChannel} from 'app/actions/views/more_dms'; @@ -13,55 +12,22 @@ import {General} from 'mattermost-redux/constants'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getCurrentUserId, getProfilesInCurrentTeam, getUsers} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, getUsers} from 'mattermost-redux/selectors/entities/users'; import MoreDirectMessages from './more_dms'; -function sortCurrentUsers(profiles) { - return Object.values(profiles).sort((a, b) => { - const nameA = a.username; - const nameB = b.username; - - return nameA.localeCompare(nameB); - }); -} - -const getUsersInCurrentTeamForMoreDirectMessages = createSelector( - getProfilesInCurrentTeam, - sortCurrentUsers -); - -const getUsersForMoreDirectMessages = createSelector( - getUsers, - sortCurrentUsers -); - function mapStateToProps(state) { - const {searchProfiles: searchRequest} = state.requests.users; - const config = getConfig(state); - - let getRequest; - let profiles; - if (config.RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY) { - getRequest = state.requests.users.getProfiles; - profiles = getUsersForMoreDirectMessages(state); - } else { - getRequest = state.requests.users.getProfilesInTeam; - profiles = getUsersInCurrentTeamForMoreDirectMessages(state); - } + const restrictDirectMessage = config.RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY; return { - config, + restrictDirectMessage, teammateNameDisplay: getTeammateNameDisplaySetting(state), allProfiles: getUsers(state), - profiles, theme: getTheme(state), currentDisplayName: state.views.channel.displayName, currentUserId: getCurrentUserId(state), currentTeamId: getCurrentTeamId(state), - getRequest, - searchRequest, }; } diff --git a/app/screens/more_dms/more_dms.js b/app/screens/more_dms/more_dms.js index 81990323c..aac978391 100644 --- a/app/screens/more_dms/more_dms.js +++ b/app/screens/more_dms/more_dms.js @@ -3,29 +3,25 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {injectIntl, intlShape} from 'react-intl'; -import { - InteractionManager, - Platform, - StyleSheet, - View, -} from 'react-native'; +import {intlShape} from 'react-intl'; +import {Platform, View} from 'react-native'; -import {General, RequestStatus} from 'mattermost-redux/constants'; +import {debounce} from 'mattermost-redux/actions/helpers'; +import {General} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {getGroupDisplayNameFromUserIds} from 'mattermost-redux/utils/channel_utils'; import {displayUsername, filterProfilesMatchingTerm} from 'mattermost-redux/utils/user_utils'; -import CustomFlatList from 'app/components/custom_flat_list'; -import CustomSectionList from 'app/components/custom_section_list'; +import CustomList, {FLATLIST, SECTIONLIST} from 'app/components/custom_list'; import UserListRow from 'app/components/custom_list/user_list_row'; +import FormattedText from 'app/components/formatted_text'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; import Loading from 'app/components/loading'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import {alertErrorWithFallback} from 'app/utils/general'; -import {loadingText} from 'app/utils/member_list'; -import {changeOpacity, setNavigatorStyles} from 'app/utils/theme'; +import {createProfilesSections, loadingText} from 'app/utils/member_list'; +import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; import SelectedUsers from './selected_users'; @@ -33,20 +29,8 @@ import SelectedUsers from './selected_users'; const START_BUTTON = 'start-conversation'; const CLOSE_BUTTON = 'close-dms'; -class MoreDirectMessages extends PureComponent { +export default class MoreDirectMessages extends PureComponent { static propTypes = { - currentDisplayName: PropTypes.string, - intl: intlShape.isRequired, - navigator: PropTypes.object, - config: PropTypes.object.isRequired, - currentUserId: PropTypes.string.isRequired, - currentTeamId: PropTypes.string.isRequired, - teammateNameDisplay: PropTypes.string, - theme: PropTypes.object.isRequired, - allProfiles: PropTypes.object.isRequired, - profiles: PropTypes.array.isRequired, - getRequest: PropTypes.object.isRequired, - searchRequest: PropTypes.object.isRequired, actions: PropTypes.shape({ makeDirectChannel: PropTypes.func.isRequired, makeGroupChannel: PropTypes.func.isRequired, @@ -55,198 +39,81 @@ class MoreDirectMessages extends PureComponent { searchProfiles: PropTypes.func.isRequired, setChannelDisplayName: PropTypes.func.isRequired, }).isRequired, + allProfiles: PropTypes.object.isRequired, + currentDisplayName: PropTypes.string, + currentTeamId: PropTypes.string.isRequired, + currentUserId: PropTypes.string.isRequired, + navigator: PropTypes.object, + restrictDirectMessage: PropTypes.bool.isRequired, + teammateNameDisplay: PropTypes.string, + theme: PropTypes.object.isRequired, }; - constructor(props) { - super(props); + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props, context) { + super(props, context); this.searchTimeoutId = 0; + this.next = true; + this.page = -1; this.state = { - profiles: props.profiles.slice(0, General.PROFILE_CHUNK_SIZE), - page: 0, - next: true, - searching: false, - showNoResults: false, + profiles: [], + searchResults: [], + loading: false, term: '', - canStartConversation: false, - loadingChannel: false, - canSelect: true, + startingConversation: false, selectedIds: {}, selectedCount: 0, }; props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); - this.updateNavigationButtons(false); + this.updateNavigationButtons(false, context); } componentDidMount() { - // set the timeout to 400 cause is the time that the modal takes to open - // Somehow interactionManager doesn't care - setTimeout(() => { - this.getProfiles(0); - }, 400); + this.getProfiles(); } - componentWillReceiveProps(nextProps) { - if (this.props.theme !== nextProps.theme) { - setNavigatorStyles(this.props.navigator, nextProps.theme); - } + componentDidUpdate(prevProps) { + const {navigator, theme} = this.props; + const {selectedCount, startingConversation} = this.state; + const canStart = selectedCount > 0 && !startingConversation; - const {getRequest} = this.props; - if (getRequest.status === RequestStatus.STARTED && - nextProps.getRequest.status === RequestStatus.SUCCESS) { - const profiles = this.sliceProfiles(nextProps.profiles); - this.setState({profiles, showNoResults: true}); - } else if ( - this.state.searching && - nextProps.searchRequest.status === RequestStatus.SUCCESS - ) { - let profiles = nextProps.profiles; - if (this.state.selectedCount > 0) { - profiles = this.removeCurrentUserFromProfiles(profiles); - } + this.updateNavigationButtons(canStart); - const exactMatches = []; - const results = filterProfilesMatchingTerm(profiles, this.state.term).filter((p) => { - if (p.username === this.state.term || p.username.startsWith(this.state.term)) { - exactMatches.push(p); - return false; - } - - return true; - }); - - this.setState({profiles: [...exactMatches, ...results], showNoResults: true}); + if (theme !== prevProps.theme) { + setNavigatorStyles(navigator, theme); } } - componentDidUpdate(prevProps, prevState) { - const startEnabled = this.isStartEnabled(this.state); - const wasStartEnabled = this.isStartEnabled(prevState); - - if (startEnabled && !wasStartEnabled) { - this.updateNavigationButtons(true); - } else if (!startEnabled && !wasStartEnabled) { - this.updateNavigationButtons(false); - } - } - - removeCurrentUserFromProfiles(profiles = []) { - return profiles.filter((profile) => { - return profile.id !== this.props.currentUserId; - }); - } - - sliceProfiles(profiles = []) { - return profiles.slice(0, (this.state.page + 1) * General.PROFILE_CHUNK_SIZE); - } - - isStartEnabled = (state) => { - if (state.loadingChannel) { - return false; - } - - return state.selectedCount >= 1 && state.selectedCount <= General.MAX_USERS_IN_GM - 1; - }; - - updateNavigationButtons = (startEnabled) => { - this.props.navigator.setButtons({ - rightButtons: [{ - id: START_BUTTON, - title: this.props.intl.formatMessage({id: 'mobile.more_dms.start', defaultMessage: 'Start'}), - showAsAction: 'always', - disabled: !startEnabled, - }], - }); - }; - close = () => { - this.props.navigator.dismissModal({ - animationType: 'slide-down', - }); - }; - - onNavigatorEvent = (event) => { - if (event.type === 'NavBarButtonPress') { - if (event.id === START_BUTTON) { - this.startConversation(); - } else if (event.id === CLOSE_BUTTON) { - this.close(); - } - } - }; - - onSearch = (text) => { - const term = text; - - if (term) { - this.setState({searching: true, term}); - clearTimeout(this.searchTimeoutId); - - this.searchTimeoutId = setTimeout(() => { - this.searchProfiles(term.toLowerCase()); - }, General.SEARCH_TIMEOUT_MILLISECONDS); - } else { - this.clearSearch(); - } + this.props.navigator.dismissModal({animationType: 'slide-down'}); }; clearSearch = () => { - const {profiles} = this.props; - - let newProfiles; - if (this.state.selectedCount > 0) { - newProfiles = this.removeCurrentUserFromProfiles(profiles); - } else { - newProfiles = this.sliceProfiles(profiles); - } - - this.setState({ - searching: false, - term: '', - page: 0, - profiles: newProfiles, - }); + this.setState({term: '', searchResults: []}); }; - getProfiles = (page) => { - if (this.props.config.RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY) { - return this.props.actions.getProfiles(page, General.PROFILE_CHUNK_SIZE); - } + getProfiles = debounce(() => { + const {loading, term} = this.state; + if (this.next && !loading && !term) { + this.setState({loading: true}, () => { + const {actions, currentTeamId, restrictDirectMessage} = this.props; - return this.props.actions.getProfilesInTeam(this.props.currentTeamId, page, General.PROFILE_CHUNK_SIZE); - }; - - searchProfiles = (term) => { - if (this.props.config.RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY) { - return this.props.actions.searchProfiles(term); - } - - return this.props.actions.searchProfiles(term, {team_id: this.props.currentTeamId}); - }; - - loadMoreProfiles = () => { - if (this.state.searching) { - return; - } - - let {page} = this.state; - if (this.props.getRequest.status !== RequestStatus.STARTED && this.state.next && !this.state.searching) { - page += 1; - this.getProfiles(page).then(({data}) => { - if (data && data.length) { - this.setState({ - page, - }); + if (restrictDirectMessage) { + actions.getProfiles(this.page + 1, General.PROFILE_CHUNK_SIZE).then(this.loadedProfiles); } else { - this.setState({next: false}); + actions.getProfilesInTeam(currentTeamId, this.page + 1, General.PROFILE_CHUNK_SIZE).then(this.loadedProfiles); } }); } - }; + }, 100); - handleSelectUser = (id) => { + handleSelectProfile = (id) => { const {currentUserId} = this.props; if (id === currentUserId) { @@ -256,9 +123,7 @@ class MoreDirectMessages extends PureComponent { this.startConversation(selectedId); } else { this.setState((prevState) => { - const { - selectedIds, - } = prevState; + const {selectedIds} = prevState; const wasSelected = selectedIds[id]; @@ -282,107 +147,42 @@ class MoreDirectMessages extends PureComponent { } }; - handleRemoveUser = (id) => { + handleRemoveProfile = (id) => { this.setState((prevState) => { - const { - profiles, - selectedCount, - selectedIds, - } = prevState; + const {selectedIds} = prevState; const newSelectedIds = Object.assign({}, selectedIds); Reflect.deleteProperty(newSelectedIds, id); - let newProfiles = profiles; - if (selectedCount === 1) { - newProfiles = this.sliceProfiles(this.props.profiles); - } - return { - profiles: newProfiles, selectedIds: newSelectedIds, selectedCount: Object.keys(newSelectedIds).length, }; }); - } - - startConversation = async (selectedId) => { - const { - currentDisplayName, - actions, - } = this.props; - - if (this.state.loadingChannel) { - return; - } - - this.setState({ - loadingChannel: true, - }); - - // Save the current channel display name in case it fails - const currentChannelDisplayName = currentDisplayName; - - const selectedIds = selectedId ? Object.keys(selectedId) : Object.keys(this.state.selectedIds); - let success; - if (selectedIds.length === 0) { - success = false; - } else if (selectedIds.length > 1) { - success = await this.makeGroupChannel(selectedIds); - } else { - success = await this.makeDirectChannel(selectedIds[0]); - } - - if (success) { - EventEmitter.emit('close_channel_drawer'); - InteractionManager.runAfterInteractions(() => { - this.close(); - }); - } else { - this.setState({ - loadingChannel: false, - }); - - actions.setChannelDisplayName(currentChannelDisplayName); - } }; - makeGroupChannel = async (ids) => { - const { - actions, - allProfiles, - currentUserId, - intl, - teammateNameDisplay, - } = this.props; - - const result = await actions.makeGroupChannel(ids); - - const displayName = getGroupDisplayNameFromUserIds(ids, allProfiles, currentUserId, teammateNameDisplay); - actions.setChannelDisplayName(displayName); - - if (result.error) { - alertErrorWithFallback( - intl, - result.error, - { - id: t('mobile.open_gm.error'), - defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.", - } - ); + isStartEnabled = (state) => { + if (state.startingConversation) { + return false; } - return !result.error; + return state.selectedCount >= 1 && state.selectedCount <= General.MAX_USERS_IN_GM - 1; + }; + + loadedProfiles = ({data}) => { + const {profiles} = this.state; + if (data && !data.length) { + this.next = false; + } + + this.page += 1; + this.setState({loading: false, profiles: [...profiles, ...data]}); }; makeDirectChannel = async (id) => { - const { - actions, - allProfiles, - intl, - teammateNameDisplay, - } = this.props; + const {intl} = this.context; + const {actions, allProfiles, teammateNameDisplay} = this.props; const user = allProfiles[id]; @@ -408,19 +208,129 @@ class MoreDirectMessages extends PureComponent { return !result.error; }; - sectionKeyExtractor = (user) => { - // Group items alphabetically by first letter of username - return user.username[0].toUpperCase(); - } + makeGroupChannel = async (ids) => { + const {intl} = this.context; + const { + actions, + allProfiles, + currentUserId, + teammateNameDisplay, + } = this.props; - compareItems = (a, b) => { - return a.username.localeCompare(b.username); + const result = await actions.makeGroupChannel(ids); + const displayName = getGroupDisplayNameFromUserIds(ids, allProfiles, currentUserId, teammateNameDisplay); + actions.setChannelDisplayName(displayName); + + if (result.error) { + alertErrorWithFallback( + intl, + result.error, + { + id: t('mobile.open_gm.error'), + defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.", + } + ); + } + + return !result.error; + }; + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + if (event.id === START_BUTTON) { + this.startConversation(); + } else if (event.id === CLOSE_BUTTON) { + this.close(); + } + } + }; + + onSearch = (text) => { + const term = text.toLowerCase(); + + if (term) { + this.setState({term}); + clearTimeout(this.searchTimeoutId); + + this.searchTimeoutId = setTimeout(() => { + this.searchProfiles(term); + }, General.SEARCH_TIMEOUT_MILLISECONDS); + } else { + this.clearSearch(); + } + }; + + searchProfiles = (term) => { + const {actions, currentTeamId, restrictDirectMessage} = this.props; + this.setState({loading: true}); + + if (restrictDirectMessage) { + actions.searchProfiles(term).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + } else { + actions.searchProfiles(term, {team_id: currentTeamId}).then(({data}) => { + this.setState({searchResults: data, loading: false}); + }); + } + }; + + startConversation = async (selectedId) => { + const { + currentDisplayName, + actions, + } = this.props; + + if (this.state.startingConversation) { + return; + } + + this.setState({ + startingConversation: true, + }); + + // Save the current channel display name in case it fails + const currentChannelDisplayName = currentDisplayName; + + const selectedIds = selectedId ? Object.keys(selectedId) : Object.keys(this.state.selectedIds); + let success; + if (selectedIds.length === 0) { + success = false; + } else if (selectedIds.length > 1) { + success = await this.makeGroupChannel(selectedIds); + } else { + success = await this.makeDirectChannel(selectedIds[0]); + } + + if (success) { + EventEmitter.emit('close_channel_drawer'); + requestAnimationFrame(() => { + this.close(); + }); + } else { + this.setState({ + startingConversation: false, + }); + + actions.setChannelDisplayName(currentChannelDisplayName); + } + }; + + updateNavigationButtons = (startEnabled, context = this.context) => { + const {formatMessage} = context.intl; + this.props.navigator.setButtons({ + rightButtons: [{ + id: START_BUTTON, + title: formatMessage({id: 'mobile.more_dms.start', defaultMessage: 'Start'}), + showAsAction: 'always', + disabled: !startEnabled, + }], + }); }; renderItem = (props) => { // The list will re-render when the selection changes because it's passed into the list as extraData const selected = this.state.selectedIds[props.id]; - const enabled = selected || this.state.selectedCount < General.MAX_USERS_IN_GM - 1; return ( ); }; + renderLoading = () => { + const {theme} = this.props; + const {loading} = this.state; + const style = getStyleFromTheme(theme); + + if (!loading) { + return null; + } + + return ( + + + + ); + }; + + renderNoResults = () => { + const {loading} = this.state; + const {theme} = this.props; + const style = getStyleFromTheme(theme); + + if (loading || this.page === -1) { + return null; + } + + return ( + + + + ); + }; + render() { + const {formatMessage} = this.context.intl; + const {currentUserId, theme} = this.props; const { - getRequest, - searchRequest, - theme, - } = this.props; - const { - loadingChannel, - showNoResults, + loading, + profiles, + searchResults, + selectedIds, + selectedCount, + startingConversation, term, } = this.state; + const style = getStyleFromTheme(theme); - const isLoading = ( - getRequest.status === RequestStatus.STARTED) || (getRequest.status === RequestStatus.NOT_STARTED) || - (searchRequest.status === RequestStatus.STARTED); - - if (loadingChannel) { + if (startingConversation) { return ( @@ -469,47 +416,37 @@ class MoreDirectMessages extends PureComponent { }), }; - let listComponent; - if (term.length) { - listComponent = ( - - ); + let data; + let listType; + if (term) { + const exactMatches = []; + const results = filterProfilesMatchingTerm(searchResults, term).filter((p) => { + if (selectedCount > 0 && p.id === currentUserId) { + return false; + } + + if (p.username === term || p.username.startsWith(term)) { + exactMatches.push(p); + return false; + } + + return true; + }); + data = [...exactMatches, ...results]; + listType = FLATLIST; } else { - listComponent = ( - - ); + data = createProfilesSections(profiles); + listType = SECTIONLIST; } return ( - + - {listComponent} + ); } } -const style = StyleSheet.create({ - searchContainer: { - marginVertical: 5, - }, +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginVertical: 5, + }, + loadingContainer: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 70, + justifyContent: 'center', + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + noResultContainer: { + flexGrow: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; }); - -export default injectIntl(MoreDirectMessages); diff --git a/app/utils/member_list.js b/app/utils/member_list.js index c3a9e9601..3595a3c7b 100644 --- a/app/utils/member_list.js +++ b/app/utils/member_list.js @@ -8,20 +8,33 @@ export const loadingText = { defaultMessage: 'Loading Members...', }; -export function createMembersSections(data) { +const sectionKeyExtractor = (profile) => { + // Group items alphabetically by first letter of username + return profile.username[0].toUpperCase(); +}; + +export function createProfilesSections(profiles) { const sections = {}; - data.forEach((d) => { - const name = d.username; - const sectionKey = name.substring(0, 1).toUpperCase(); + const sectionKeys = []; + for (const profile of profiles) { + const sectionKey = sectionKeyExtractor(profile); if (!sections[sectionKey]) { sections[sectionKey] = []; + sectionKeys.push(sectionKey); } - sections[sectionKey].push(d); - }); + sections[sectionKey].push(profile); + } - return sections; + sectionKeys.sort(); + + return sectionKeys.map((sectionKey) => { + return { + id: sectionKey, + data: sections[sectionKey], + }; + }); } export function markSelectedProfiles(profiles, selectedProfiles) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f8f7a783c..a58f21225 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -171,6 +171,7 @@ "mobile.channel_list.not_member": "NOT A MEMBER", "mobile.channel_list.unreads": "UNREADS", "mobile.channel.markAsRead": "Mark As Read", + "mobile.channel_members.add_members_alert": "You must select at least one member to add to the channel.", "mobile.client_upgrade": "Update App", "mobile.client_upgrade.can_upgrade_subtitle": "A new version is available for download.", "mobile.client_upgrade.can_upgrade_title": "Update Available", @@ -260,6 +261,7 @@ "mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.", "mobile.loading_channels": "Loading Channels...", "mobile.loading_members": "Loading Members...", + "mobile.loading_options": "Loading Options...", "mobile.loading_posts": "Loading messages...", "mobile.login_options.choose_title": "Choose your login method", "mobile.long_post_title": "{channelName} - Post", @@ -429,6 +431,7 @@ "modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", + "more_channels.noMore": "No more channels to join", "more_channels.title": "More Channels", "msg_typing.areTyping": "{users} and {last} are typing...", "msg_typing.isTyping": "{user} is typing...",