Refactor CustomList to use Section and Flat Lists (#2332)

This commit is contained in:
Elias Nahum 2018-11-13 20:16:57 -03:00 committed by Harrison Healey
parent b5ddce6770
commit 592fab2375
26 changed files with 2103 additions and 2416 deletions

View file

@ -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 <RowComponent {...props}/>;
}
return this.props.renderItem(props);
};
renderItemSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return <View style={style.separator}/>;
};
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 (
<View style={style.loading}>
<FormattedText
{...this.props.loadingText}
style={style.loadingText}
/>
</View>
);
};
renderEmptyList = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (this.props.loading) {
return (
<View style={style.searching}>
<Loading/>
</View>
);
}
if (this.props.showNoResults) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return null;
};
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<FlatList
style={style.listView}
initialNumToRender={this.props.initialNumToRender}
ItemSeparatorComponent={this.renderItemSeparator}
ListFooterComponent={this.renderFooter}
ListEmptyComponent={this.renderEmptyList}
onEndReached={this.props.onListEndReached}
onEndReachedThreshold={this.props.onListEndReachedThreshold}
extraData={this.props.extraData}
data={this.state.items}
keyExtractor={this.props.keyExtractor}
renderItem={this.renderItem}
keyboardShouldPersistTaps='handled'
/>
);
}
}
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),
},
};
});

View file

@ -1,64 +1,102 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomList should match snapshot 1`] = `
<Component
exports[`CustomList should match snapshot with FlatList 1`] = `
<FlatList
ItemSeparatorComponent={[Function]}
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"flexGrow": 1,
}
}
data={
Array [
Object {
"username": "username_1",
},
Object {
"username": "username_2",
},
]
}
disableVirtualization={false}
horizontal={false}
initialNumToRender={15}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={16}
numColumns={1}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={60}
stickySectionHeadersEnabled={true}
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<ListViewMock
dataSource={
ListViewDataSource {
"items": 2,
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;
exports[`CustomList should match snapshot with SectionList 1`] = `
<SectionList
ItemSeparatorComponent={[Function]}
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]}
contentContainerStyle={
Object {
"flexGrow": 1,
}
enableEmptySections={true}
initialListSize={0}
onEndReached={[Function]}
onEndReachedThreshold={0}
pageSize={0}
renderFooter={[Function]}
renderRow={[Function]}
renderScrollComponent={[Function]}
renderSectionHeader={[Function]}
renderSeparator={[Function]}
scrollRenderAheadDistance={0}
style={
}
data={Array []}
disableVirtualization={false}
extraData={false}
horizontal={false}
initialNumToRender={15}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={16}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
removeClippedSubviews={true}
renderItem={[Function]}
renderSectionHeader={[Function]}
scrollEventThrottle={60}
sections={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
"username": "username_1",
},
Object {
"username": "username_2",
},
]
}
stickySectionHeadersEnabled={false}
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
/>
</Component>
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;
exports[`CustomList should match snapshot, renderFooter 1`] = `null`;
exports[`CustomList should match snapshot, renderFooter 2`] = `
<Component
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"height": 70,
"justifyContent": "center",
}
}
>
<FormattedText
defaultMessage="Loading Members..."
id="mobile.loading_members"
style={
Object {
"color": "rgba(61,60,64,0.6)",
}
}
/>
</Component>
`;
exports[`CustomList should match snapshot, renderFooter 2`] = `<Component />`;
exports[`CustomList should match snapshot, renderSectionHeader 1`] = `
<Component

View file

@ -59,6 +59,7 @@ export default class CustomListRow extends React.PureComponent {
const style = StyleSheet.create({
touchable: {
flex: 1,
overflow: 'hidden',
},
container: {
flexDirection: 'row',

View file

@ -1,246 +1,230 @@
// 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 {ListView, Platform, Text, View} from 'react-native';
import {FlatList, Platform, SectionList, Text, View} from 'react-native';
import Loading from 'app/components/loading';
import FormattedText from 'app/components/formatted_text';
import {ListTypes} from 'app/constants';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export const FLATLIST = 'flat';
export const SECTIONLIST = 'section';
const INITIAL_BATCH_TO_RENDER = 15;
const SCROLL_UP_MULTIPLIER = 6;
export default class CustomList extends PureComponent {
static propTypes = {
data: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired,
searching: PropTypes.bool,
onListEndReached: PropTypes.func,
onListEndReachedThreshold: PropTypes.number,
extraData: PropTypes.any,
listType: PropTypes.oneOf([FLATLIST, SECTIONLIST]),
loading: PropTypes.bool,
loadingText: PropTypes.object,
listPageSize: PropTypes.number,
listInitialSize: PropTypes.number,
listScrollRenderAheadDistance: PropTypes.number,
showSections: PropTypes.bool,
loadingComponent: PropTypes.element,
noResults: PropTypes.element,
onLoadMore: PropTypes.func,
onRowPress: PropTypes.func,
selectable: PropTypes.bool,
onRowSelect: PropTypes.func,
renderRow: PropTypes.func.isRequired,
createSections: PropTypes.func,
showNoResults: PropTypes.bool,
renderItem: PropTypes.oneOfType([
PropTypes.func,
PropTypes.element,
]).isRequired,
selectable: PropTypes.bool,
theme: PropTypes.object.isRequired,
};
static defaultProps = {
searching: false,
onListEndReached: () => 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 (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{sectionId}</Text>
</View>
</View>
);
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 <RowComponent {...props}/>;
}
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 (
<View
key={`${sectionId}-${rowId}`}
style={style.separator}
<FlatList
contentContainerStyle={style.container}
data={data}
extraData={extraData}
keyboardShouldPersistTaps='always'
keyboardDismissMode='interactive'
keyExtractor={this.keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ItemSeparatorComponent={this.renderSeparator}
ListEmptyComponent={this.renderEmptyList}
ListFooterComponent={this.renderFooter}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
ref={this.setListRef}
removeClippedSubviews={true}
renderItem={this.renderItem}
scrollEventThrottle={60}
style={style.list}
stickySectionHeadersEnabled={true}
/>
);
};
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 (
<View style={{height: 70, backgroundColor, alignItems: 'center', justifyContent: 'center'}}>
<FormattedText
{...this.props.loadingText}
style={style.loadingText}
/>
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{section.id}</Text>
</View>
</View>
);
};
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 = (
<View
style={style.searching}
>
<Loading/>
</View>
);
}
if (showNoResults && noResults) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return (
<View style={style.container}>
<ListView
style={style.listView}
dataSource={dataSource}
renderRow={this.renderRow}
renderSectionHeader={showSections ? this.renderSectionHeader : null}
renderSeparator={this.renderSeparator}
renderFooter={this.renderFooter}
enableEmptySections={true}
onEndReached={onListEndReached}
onEndReachedThreshold={onListEndReachedThreshold}
pageSize={listPageSize}
initialListSize={listInitialSize}
scrollRenderAheadDistance={listScrollRenderAheadDistance}
/>
{loadingComponent}
</View>
<SectionList
contentContainerStyle={style.container}
extraData={loading}
keyboardShouldPersistTaps='always'
keyboardDismissMode='interactive'
keyExtractor={this.keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ItemSeparatorComponent={this.renderSeparator}
ListEmptyComponent={this.renderEmptyList}
ListFooterComponent={this.renderFooter}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
ref={this.setListRef}
removeClippedSubviews={true}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
scrollEventThrottle={60}
sections={data}
style={style.list}
stickySectionHeadersEnabled={false}
/>
);
};
renderSeparator = () => {
const style = getStyleFromTheme(this.props.theme);
return (
<View style={style.separator}/>
);
};
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',

View file

@ -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: (<View/>),
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(
<CustomList {...baseProps}/>
);
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(
<CustomList {...props}/>
);
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(
<CustomList {...baseProps}/>
);
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(
<CustomList {...props}/>
);
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(
<CustomList {...baseProps}/>
);
expect(wrapper.instance().renderSeparator('section_id', 'row_id')).toMatchSnapshot();
expect(wrapper.instance().renderSeparator()).toMatchSnapshot();
});
test('should match snapshot, renderFooter', () => {

View file

@ -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,

View file

@ -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",
}
}
>

View file

@ -119,6 +119,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
flex: 1,
flexDirection: 'row',
marginHorizontal: 10,
overflow: 'hidden',
},
profileContainer: {
flexDirection: 'row',

View file

@ -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 (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{section.key}</Text>
</View>
</View>
);
};
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 <RowComponent {...props}/>;
}
return this.props.renderItem(props);
};
renderItemSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.separator}/>
);
};
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 (
<View style={style.loading}>
<FormattedText
{...this.props.loadingText}
style={style.loadingText}
/>
</View>
);
};
renderEmptyList = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (this.props.loading) {
return (
<View
style={style.searching}
>
<Loading/>
</View>
);
}
if (this.props.showNoResults) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return null;
}
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<SectionList
style={style.listView}
initialNumToRender={this.props.initialNumToRender}
renderSectionHeader={this.renderSectionHeader}
ItemSeparatorComponent={this.renderItemSeparator}
ListFooterComponent={this.renderFooter}
ListEmptyComponent={this.renderEmptyList}
onEndReached={this.props.onListEndReached}
onEndReachedThreshold={this.props.onListEndReachedThreshold}
extraData={this.props.extraData}
sections={this.state.sections}
keyExtractor={this.props.keyExtractor}
renderItem={this.renderItem}
keyboardShouldPersistTaps='handled'
/>
);
}
}
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),
},
};
});

View file

@ -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 {

View file

@ -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 (
<UserListRow
key={props.id}
{...props}
selectable={true}
selected={selected}
enabled={true}
/>
);
};
renderLoading = () => {
const {theme} = this.props;
const {loading} = this.state;
const style = getStyleFromTheme(theme);
if (!loading) {
return null;
}
return (
<View style={style.loadingContainer}>
<FormattedText
{...loadingText}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
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 (
<KeyboardLayout>
<View style={style.container}>
<StatusBar/>
<Loading/>
</KeyboardLayout>
</View>
);
}
@ -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 (
<KeyboardLayout>
<StatusBar/>
<View
style={{marginVertical: 5}}
>
<View style={style.searchBar}>
<SearchBar
ref='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
@ -272,31 +340,57 @@ class ChannelAddMembers extends PureComponent {
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.clearSearch}
autoCapitalize='none'
value={term}
/>
</View>
<CustomList
data={profiles}
data={data}
extraData={selectedIds}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.getProfiles}
onRowPress={this.handleSelectProfile}
renderItem={this.renderItem}
theme={theme}
searching={searching}
onListEndReached={more}
preferences={preferences}
listScrollRenderAheadDistance={50}
loading={this.state.isLoading}
loadingText={loadingText}
selectable={this.state.canSelect}
onRowSelect={this.handleRowSelect}
renderRow={UserListRow}
createSections={createMembersSections}
showNoResults={this.state.showNoResults}
/>
</KeyboardLayout>
);
}
}
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),
},
};
});

View file

@ -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),
};
}

View file

@ -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 (
<UserListRow
key={props.id}
{...props}
selectable={true}
selected={selected}
enabled={enabled}
/>
);
};
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 (
<View style={style.loadingContainer}>
<FormattedText
{...loadingText}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
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 (
<KeyboardLayout>
<View style={style.container}>
<StatusBar/>
<Loading/>
</KeyboardLayout>
</View>
);
}
@ -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 (
<KeyboardLayout>
<StatusBar/>
<View
style={{marginVertical: 5}}
>
<View style={style.searchBar}>
<SearchBar
ref='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
@ -314,29 +359,57 @@ class ChannelMembers extends PureComponent {
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.clearSearch}
autoCapitalize='none'
value={term}
/>
</View>
<CustomList
data={profiles}
data={data}
extraData={selectedIds}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.getProfiles}
onRowPress={this.handleSelectProfile}
renderItem={this.renderItem}
theme={theme}
searching={searching}
onListEndReached={more}
listScrollRenderAheadDistance={50}
loading={isLoading}
loadingText={loadingText}
onRowSelect={canManageUsers && this.state.canSelect ? this.handleRowSelect : null}
renderRow={this.renderMemberRow}
createSections={createMembersSections}
showNoResults={showNoResults}
/>
</KeyboardLayout>
);
}
}
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),
},
};
});

View file

@ -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),
};
};
}

View file

@ -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 {

View file

@ -1,525 +1,524 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MenuActionSelector should match snapshot for channels 1`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"display_name": "display_name",
"id": "id",
"name": "name",
},
"marginVertical": 5,
}
}
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</Component>
<CustomList
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"display_name": "display_name2",
"id": "id2",
"name": "name2",
},
]
}
dataSource="channels"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
/>
</Component>
`;
exports[`MenuActionSelector should match snapshot for channels 2`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"display_name": "display_name",
"id": "id",
"name": "name",
},
"marginVertical": 5,
}
}
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</Component>
<CustomList
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"display_name": "display_name2",
"id": "id2",
"name": "name2",
},
]
}
dataSource="channels"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
/>
</Component>
`;
exports[`MenuActionSelector should match snapshot for explicit options 1`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"text": "text",
"value": "value",
},
]
}
dataSource={null}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"marginVertical": 5,
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</Component>
<CustomList
data={
Array [
Object {
"text": "text",
"value": "value",
},
]
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
/>
/>
</Component>
`;
exports[`MenuActionSelector should match snapshot for searching 1`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"display_name": "display_name",
"id": "id",
"name": "name",
},
"marginVertical": 5,
}
}
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value="name2"
/>
</Component>
<CustomList
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"display_name": "display_name2",
"id": "id2",
"name": "name2",
},
]
}
dataSource="channels"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
/>
</Component>
`;
exports[`MenuActionSelector should match snapshot for users 1`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"id": "id",
"username": "username",
},
"marginVertical": 5,
}
}
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</Component>
<CustomList
data={Array []}
listType="section"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"id": "id2",
"username": "username2",
},
]
}
dataSource="users"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
/>
</Component>
`;
exports[`MenuActionSelector should match snapshot for users 2`] = `
<MenuActionSelector
actions={
<Component
style={
Object {
"getChannels": [Function],
"getProfiles": [Function],
"searchChannels": [Function],
"searchProfiles": [Function],
"backgroundColor": "#ffffff",
"flex": 1,
}
}
currentTeamId="someId"
data={
Array [
>
<Connect(StatusBar) />
<Component
style={
Object {
"id": "id",
"username": "username",
},
"marginVertical": 5,
}
}
>
<SearchBarIos
autoCapitalize="none"
backgroundColor="transparent"
blurOnSubmit={true}
cancelTitle="Cancel"
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onFocus={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</Component>
<CustomList
data={Array []}
listType="section"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"id": "id2",
"username": "username2",
},
]
}
dataSource="users"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"textComponent": "span",
"timeZone": null,
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
}
navigator={
Object {
"setOnNavigatorEvent": [MockFunction],
}
}
onSelect={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
/>
</Component>
`;

View file

@ -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,

View file

@ -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 (
<View style={style.loadingContainer}>
<FormattedText
{...text}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
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 (
<View style={style.container}>
<StatusBar/>
<View
style={style.searchContainer}
>
<View style={style.searchBar}>
<SearchBar
ref='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
@ -227,26 +320,24 @@ class MenuActionSelector extends PureComponent {
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.clearSearch}
autoCapitalize='none'
value={term}
/>
</View>
<CustomList
data={filteredData}
data={data}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.loadMore}
onRowPress={this.handleSelectItem}
renderItem={rowComponent}
theme={theme}
searching={searching}
onListEndReached={more}
listScrollRenderAheadDistance={50}
loading={this.state.isLoading}
loadingText={loadingText}
selectable={false}
onRowPress={this.handleRowSelect}
renderRow={rowComponent}
showNoResults={this.state.showNoResults}
showSections={false}
/>
</View>
);
@ -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);

View file

@ -37,7 +37,6 @@ exports[`MoreChannels should match snapshot 1`] = `
/>
</Component>
<CustomList
createSections={[Function]}
data={
Array [
Object {
@ -47,24 +46,58 @@ exports[`MoreChannels should match snapshot 1`] = `
},
]
}
listInitialSize={10}
listPageSize={10}
listScrollRenderAheadDistance={50}
extraData={false}
listType="flat"
loading={false}
loadingText={
Object {
"defaultMessage": "Loading Channels...",
"id": "mobile.loading_channels",
}
loadingComponent={
<Component
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"height": 70,
"justifyContent": "center",
}
}
>
<FormattedText
defaultMessage="Loading Channels..."
id="mobile.loading_channels"
style={
Object {
"color": "rgba(61,60,64,0.6)",
}
}
/>
</Component>
}
onListEndReached={[Function]}
onListEndReachedThreshold={50}
noResults={
<Component
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"flexGrow": 1,
"justifyContent": "center",
}
}
>
<FormattedText
defaultMessage="No more channels to join"
id="more_channels.noMore"
style={
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 26,
}
}
/>
</Component>
}
onLoadMore={[Function]}
onRowPress={[Function]}
renderRow={[Function]}
searching={false}
selectable={false}
showNoResults={false}
showSections={false}
renderItem={[Function]}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",

View file

@ -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,
};
}

View file

@ -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 (
<View style={style.loadingContainer}>
<FormattedText
id='mobile.loading_channels'
defaultMessage='Loading Channels...'
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {term, loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading) {
return null;
}
if (term) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='more_channels.noMore'
defaultMessage='No more channels to join'
style={style.noResultText}
/>
</View>
);
};
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 = (
<React.Fragment>
<View style={style.searchbar}>
<View style={style.searchBar}>
<SearchBar
ref='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
@ -332,8 +340,8 @@ export default class MoreChannels extends PureComponent {
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.searchProfiles}
onSearchButtonPress={this.searchProfiles}
onChangeText={this.searchChannels}
onSearchButtonPress={this.searchChannels}
onCancelButtonPress={this.cancelSearch}
autoCapitalize='none'
value={term}
@ -341,16 +349,15 @@ export default class MoreChannels extends PureComponent {
</View>
<CustomList
data={channels}
theme={theme}
searching={searching}
onListEndReached={more}
loading={isLoading}
listScrollRenderAheadDistance={50}
showSections={false}
renderRow={ChannelListRow}
extraData={loading}
key='custom_list'
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={more}
onRowPress={this.onSelectChannel}
loadingText={{id: t('mobile.loading_channels'), defaultMessage: 'Loading Channels...'}}
showNoResults={this.state.showNoResults}
renderItem={ChannelListRow}
theme={theme}
/>
</React.Fragment>
);
@ -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),
},
};
});

View file

@ -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(
<MoreChannels {...props}/>,
<MoreChannels {...baseProps}/>,
{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(
<MoreChannels {...baseProps}/>,
{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(
<MoreChannels {...baseProps}/>,
{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(
<MoreChannels {...props}/>,
<MoreChannels {...baseProps}/>,
{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);
});
});

View file

@ -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,
};
}

View file

@ -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 (
<UserListRow
@ -428,28 +338,65 @@ class MoreDirectMessages extends PureComponent {
{...props}
selectable={true}
selected={selected}
enabled={enabled}
enabled={true}
/>
);
};
renderLoading = () => {
const {theme} = this.props;
const {loading} = this.state;
const style = getStyleFromTheme(theme);
if (!loading) {
return null;
}
return (
<View style={style.loadingContainer}>
<FormattedText
{...loadingText}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
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 (
<View style={style.container}>
<StatusBar/>
@ -469,47 +416,37 @@ class MoreDirectMessages extends PureComponent {
}),
};
let listComponent;
if (term.length) {
listComponent = (
<CustomFlatList
theme={theme}
items={this.state.profiles}
renderItem={this.renderItem}
showNoResults={showNoResults}
extraData={this.state.selectedIds}
onRowPress={this.handleSelectUser}
loading={isLoading}
loadingText={loadingText}
/>
);
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 = (
<CustomSectionList
theme={theme}
items={this.state.profiles}
renderItem={this.renderItem}
showNoResults={showNoResults}
sectionKeyExtractor={this.sectionKeyExtractor}
compareItems={this.compareItems}
extraData={this.state.selectedIds}
onListEndReached={this.loadMoreProfiles}
listScrollRenderAheadDistance={50}
onRowPress={this.handleSelectUser}
loading={isLoading}
loadingText={loadingText}
/>
);
data = createProfilesSections(profiles);
listType = SECTIONLIST;
}
return (
<KeyboardLayout>
<StatusBar/>
<View style={style.searchContainer}>
<View style={style.searchBar}>
<SearchBar
ref='search_bar'
placeholder={this.props.intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={this.props.intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={searchBarInput}
@ -529,19 +466,53 @@ class MoreDirectMessages extends PureComponent {
warnMessage={{id: t('mobile.more_dms.add_more'), defaultMessage: 'You can add {remaining, number} more users'}}
maxCount={7}
maxMessage={{id: t('mobile.more_dms.cannot_add_more'), defaultMessage: 'You cannot add more users'}}
onRemove={this.handleRemoveUser}
onRemove={this.handleRemoveProfile}
/>
</View>
{listComponent}
<CustomList
data={data}
extraData={selectedIds}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.getProfiles}
onRowPress={this.handleSelectProfile}
renderItem={this.renderItem}
theme={theme}
/>
</KeyboardLayout>
);
}
}
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);

View file

@ -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) {

View file

@ -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...",