From 3eb7e377cfc0176fbae7e05161a3cf7bb604d015 Mon Sep 17 00:00:00 2001 From: enahum Date: Mon, 20 Feb 2017 16:51:02 -0300 Subject: [PATCH] More Channels (#276) * More Channels * Fix cleaning search * feeback review --- app/actions/navigation/index.js | 9 + app/components/{channel_list => }/badge.js | 0 .../channel_drawer_item.js} | 4 +- .../channel_drawer_list.js} | 56 ++-- .../channel_drawer_list_container.js} | 20 +- .../index.js | 2 +- .../unread_indicator.js | 0 .../custom_list/channel_list_row.js | 139 ++++++++++ .../{member_list => custom_list}/index.js | 180 ++++++------- .../member_list_row.js | 96 ++++--- app/navigation/routes.js | 8 + .../channel_add_members.js | 36 ++- .../channel_add_members_container.js | 3 +- app/scenes/channel_drawer/channel_drawer.js | 4 +- app/scenes/channel_info/channel_info.js | 9 +- .../channel_info/channel_info_container.js | 1 + app/scenes/channel_members/channel_members.js | 45 ++-- .../channel_members_container.js | 3 +- .../channel_members/channel_members_title.js | 10 +- .../channel_members/remove_member_button.js | 7 +- app/scenes/index.js | 2 + app/scenes/more_channels/index.js | 42 +++ app/scenes/more_channels/more_channels.js | 242 ++++++++++++++++++ app/scenes/more_dms/more_dms.js | 62 +++-- app/utils/member_list.js | 50 ++++ assets/base/i18n/en.json | 3 +- service/actions/channels.js | 28 ++ service/client/client.js | 7 + service/selectors/entities/channels.js | 10 +- service/utils/channel_utils.js | 8 + 30 files changed, 864 insertions(+), 222 deletions(-) rename app/components/{channel_list => }/badge.js (100%) rename app/components/{channel_list/channel_item.js => channel_drawer_list/channel_drawer_item.js} (98%) rename app/components/{channel_list/channel_list.js => channel_drawer_list/channel_drawer_list.js} (93%) rename app/components/{channel_list/channel_list_container.js => channel_drawer_list/channel_drawer_list_container.js} (69%) rename app/components/{channel_list => channel_drawer_list}/index.js (67%) rename app/components/{channel_list => channel_drawer_list}/unread_indicator.js (100%) create mode 100644 app/components/custom_list/channel_list_row.js rename app/components/{member_list => custom_list}/index.js (51%) rename app/components/{member_list => custom_list}/member_list_row.js (60%) create mode 100644 app/scenes/more_channels/index.js create mode 100644 app/scenes/more_channels/more_channels.js create mode 100644 app/utils/member_list.js diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index 4be2ab50d..c787d3215 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -130,6 +130,15 @@ export function showOptionsModal(title, options) { }; } +export function showMoreChannelsModal() { + return async (dispatch, getState) => { + dispatch({ + type: NavigationTypes.NAVIGATION_MODAL, + route: Routes.MoreChannels + }, getState); + }; +} + export function showDirectMessagesModal() { return async (dispatch, getState) => { dispatch({ diff --git a/app/components/channel_list/badge.js b/app/components/badge.js similarity index 100% rename from app/components/channel_list/badge.js rename to app/components/badge.js diff --git a/app/components/channel_list/channel_item.js b/app/components/channel_drawer_list/channel_drawer_item.js similarity index 98% rename from app/components/channel_list/channel_item.js rename to app/components/channel_drawer_list/channel_drawer_item.js index d067ce83b..24b15e970 100644 --- a/app/components/channel_list/channel_item.js +++ b/app/components/channel_drawer_list/channel_drawer_item.js @@ -10,9 +10,9 @@ import {changeOpacity} from 'app/utils/theme'; import {Constants} from 'service/constants'; -import Badge from './badge'; +import Badge from 'app/components/badge'; -export default class ChannelItem extends React.Component { +export default class ChannelDrawerItem extends React.Component { static propTypes = { channel: React.PropTypes.object.isRequired, onSelectChannel: React.PropTypes.func.isRequired, diff --git a/app/components/channel_list/channel_list.js b/app/components/channel_drawer_list/channel_drawer_list.js similarity index 93% rename from app/components/channel_list/channel_list.js rename to app/components/channel_drawer_list/channel_drawer_list.js index 174ab1873..73dcfccf9 100644 --- a/app/components/channel_list/channel_list.js +++ b/app/components/channel_drawer_list/channel_drawer_list.js @@ -15,7 +15,7 @@ import { import {injectIntl, intlShape} from 'react-intl'; import {Constants} from 'service/constants'; import LineDivider from 'app/components/line_divider'; -import ChannelItem from './channel_item'; +import ChannelDrawerItem from './channel_drawer_item'; import FormattedText from 'app/components/formatted_text'; import UnreadIndicator from './unread_indicator'; import deepEqual from 'deep-equal'; @@ -64,10 +64,19 @@ const Styles = StyleSheet.create({ fontSize: 14, textAlign: 'center', textAlignVertical: 'center' + }, + more: { + position: 'absolute', + opacity: 0.6, + right: 0, + width: 50, + height: 30, + alignItems: 'center', + justifyContent: 'center' } }); -class ChannelList extends Component { +class ChannelDrawerList extends Component { static propTypes = { intl: intlShape.isRequired, currentTeam: PropTypes.object.isRequired, @@ -85,6 +94,7 @@ class ChannelList extends Component { unmarkFavorite: PropTypes.func.isRequired, showOptionsModal: PropTypes.func.isRequired, showDirectMessagesModal: PropTypes.func.isRequired, + showMoreChannelsModal: PropTypes.func.isRequired, closeOptionsModal: PropTypes.func.isRequired }).isRequired }; @@ -337,7 +347,7 @@ class ChannelList extends Component { const unread = msgCount > 0; return ( - + + + ); + data.push( - , + + + {moreChannels} + , ...publicChannels ); data.push( @@ -398,19 +424,9 @@ class ChannelList extends Component { ...privateChannels ); - const moreDmStyle = { - position: 'absolute', - opacity: 0.6, - right: 0, - width: 50, - height: 30, - alignItems: 'center', - justifyContent: 'center' - }; - const moreDms = ( { + return StyleSheet.create({ + container: { + flexDirection: 'column', + height: 65, + paddingHorizontal: 15, + justifyContent: 'center', + backgroundColor: theme.centerChannelBg + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row' + }, + displayName: { + fontSize: 16, + color: theme.centerChannelColor + }, + icon: { + fontSize: 16, + color: theme.centerChannelColor + }, + textContainer: { + flex: 1, + flexDirection: 'row', + marginLeft: 5 + }, + purpose: { + fontSize: 13, + color: changeOpacity(theme.centerChannelColor, 0.5) + }, + selector: { + height: 28, + width: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: '#888', + alignItems: 'center', + justifyContent: 'center' + }, + selectorContainer: { + height: 50, + paddingRight: 15, + alignItems: 'center', + justifyContent: 'center' + }, + selectorFilled: { + backgroundColor: '#378FD2', + borderWidth: 0 + } + }); +}); + +function createTouchableComponent(children, action) { + return ( + + {children} + + ); +} + +function ChannelListRow(props) { + const {id, displayName, purpose, onPress, theme} = props; + const style = getStyleFromTheme(theme); + + const RowComponent = ( + + + {props.selectable && + + + + {props.selected && + + } + + + + } + + + + + {displayName} + + + + + + + {purpose} + + + + ); + + if (typeof onPress === 'function') { + return createTouchableComponent(RowComponent, () => onPress(id)); + } + + return RowComponent; +} + +ChannelListRow.propTypes = { + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + purpose: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + onPress: PropTypes.func, + selectable: PropTypes.bool, + onRowSelect: PropTypes.func, + selected: PropTypes.bool +}; + +export default ChannelListRow; diff --git a/app/components/member_list/index.js b/app/components/custom_list/index.js similarity index 51% rename from app/components/member_list/index.js rename to app/components/custom_list/index.js index fd25dddbc..22bd67b09 100644 --- a/app/components/member_list/index.js +++ b/app/components/custom_list/index.js @@ -1,58 +1,65 @@ // Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. - import React, {PropTypes, PureComponent} from 'react'; -import { - ListView, - StyleSheet, - Text, - View -} from 'react-native'; - -import {displayUsername} from 'service/utils/user_utils'; - +import {ListView, StyleSheet, Text, View} from 'react-native'; import FormattedText from 'app/components/formatted_text'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; -import MemberListRow from './member_list_row'; - -const style = StyleSheet.create({ - listView: { - flex: 1 - }, - loadingText: { - opacity: 0.6 - }, - sectionContainer: { - backgroundColor: '#eaeaea', - paddingLeft: 10, - paddingVertical: 2 - }, - sectionText: { - fontWeight: '600' - }, - separator: { - height: 1, - flex: 1, - backgroundColor: '#eaeaea' - } +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg + }, + loadingText: { + color: changeOpacity(theme.centerChannelColor, 0.6) + }, + sectionContainer: { + backgroundColor: theme.sidebarHeaderBg, + paddingLeft: 10, + paddingVertical: 2 + }, + sectionText: { + fontWeight: '600', + color: theme.sidebarHeaderTextColor + }, + 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) + } + }); }); -export default class MemberList extends PureComponent { +export default class CustomList extends PureComponent { static propTypes = { - members: PropTypes.array.isRequired, + data: PropTypes.array.isRequired, + theme: PropTypes.object.isRequired, searching: PropTypes.bool, onRowPress: PropTypes.func, onListEndReached: PropTypes.func, onListEndReachedThreshold: PropTypes.number, preferences: PropTypes.object, - loadingMembers: PropTypes.bool, + loading: PropTypes.bool, + loadingText: PropTypes.object, listPageSize: PropTypes.number, listInitialSize: PropTypes.number, listScrollRenderAheadDistance: PropTypes.number, showSections: PropTypes.bool, selectable: PropTypes.bool, onRowSelect: PropTypes.func, - renderRow: PropTypes.func + renderRow: PropTypes.func.isRequired, + createSections: PropTypes.func }; static defaultProps = { @@ -63,7 +70,9 @@ export default class MemberList extends PureComponent { listInitialSize: 10, listScrollRenderAheadDistance: 200, selectable: false, + loadingText: null, onRowSelect: () => true, + createSections: () => true, showSections: true }; @@ -74,17 +83,18 @@ export default class MemberList extends PureComponent { } componentWillReceiveProps(nextProps) { - const {members, showSections, searching} = nextProps; + const {data, showSections, searching} = nextProps; + this.showNoResults = true; if (searching || searching !== this.props.searching) { this.setState(this.buildDataSource(nextProps)); - } else if (members !== this.props.members || showSections !== this.props.showSections) { - let data = members; + } else if (data !== this.props.data || showSections !== this.props.showSections) { + let newData = data; if (showSections) { - data = this.createSections(members); + newData = this.props.createSections(data); } - const mergedData = Object.assign({}, data, this.state.data); + const mergedData = Object.assign({}, newData, this.state.data); const dataSource = showSections ? this.state.dataSource.cloneWithRowsAndSections(mergedData) : this.state.dataSource.cloneWithRows(mergedData); this.setState({ data: mergedData, @@ -98,33 +108,17 @@ export default class MemberList extends PureComponent { rowHasChanged: (r1, r2) => r1 !== r2, sectionHeaderHasChanged: (s1, s2) => s1 !== s2 }); - let data = props.members; + let newData = props.data; if (props.showSections) { - data = this.createSections(props.members); + newData = this.props.createSections(props.data); } - const dataSource = ds.cloneWithRowsAndSections(data); + const dataSource = props.showSections ? ds.cloneWithRowsAndSections(newData) : ds.cloneWithRows(newData); return { - data, + data: newData, dataSource }; }; - createSections = (data) => { - const sections = {}; - data.forEach((d) => { - const name = d.username; - const sectionKey = name.substring(0, 1).toUpperCase(); - - if (!sections[sectionKey]) { - sections[sectionKey] = []; - } - - sections[sectionKey].push(d); - }); - - return sections; - }; - handleRowSelect = (sectionId, rowId) => { const data = this.state.data; const section = [...data[sectionId]]; @@ -142,6 +136,7 @@ export default class MemberList extends PureComponent { }; renderSectionHeader = (sectionData, sectionId) => { + const style = getStyleFromTheme(this.props.theme); return ( {sectionId} @@ -149,29 +144,21 @@ export default class MemberList extends PureComponent { ); }; - renderRow = (user, sectionId, rowId) => { - const {id, username} = user; - const displayName = displayUsername(user, this.props.preferences); - let onRowSelect = null; - if (this.props.selectable) { - onRowSelect = () => this.handleRowSelect(sectionId, rowId); - } - - return ( - + renderRow = (rowData, sectionId, rowId) => { + return this.props.renderRow( + rowData, + sectionId, + rowId, + this.props.preferences, + this.props.theme, + this.props.selectable, + this.props.onRowPress, + this.handleRowSelect ); }; renderSeparator = (sectionId, rowId) => { + const style = getStyleFromTheme(this.props.theme); return ( { - if (!this.props.loadingMembers) { + const {theme} = this.props; + const style = getStyleFromTheme(theme); + if (!this.props.loading || !this.props.loadingText) { return null; } - const backgroundColor = this.props.members.length > 0 ? '#fff' : '#0000'; + const backgroundColor = this.props.data.length > 0 ? theme.centerChannelBg : '#0000'; return ( @@ -199,14 +187,32 @@ export default class MemberList extends PureComponent { }; render() { - const renderRow = this.props.renderRow || this.renderRow; + const style = getStyleFromTheme(this.props.theme); + let noResults = false; + if (typeof this.props.data === 'object') { + noResults = Object.keys(this.props.data).length === 0; + } else { + noResults = this.props.data.length === 0; + } + + if (this.showNoResults && !this.props.loading && noResults) { + return ( + + + + ); + } return ( { + return StyleSheet.create({ + container: { + flexDirection: 'row', + height: 65, + paddingHorizontal: 15, + alignItems: 'center', + backgroundColor: theme.centerChannelBg + }, + displayName: { + fontSize: 16, + color: theme.centerChannelColor + }, + icon: { + fontSize: 20, + color: theme.centerChannelColor + }, + textContainer: { + flex: 1, + flexDirection: 'row', + marginLeft: 5 + }, + username: { + marginLeft: 5, + fontSize: 16, + color: changeOpacity(theme.centerChannelColor, 0.5) + }, + selector: { + height: 28, + width: 28, + borderRadius: 14, + borderWidth: 1, + borderColor: '#888', + alignItems: 'center', + justifyContent: 'center' + }, + selectorContainer: { + height: 50, + paddingRight: 15, + alignItems: 'center', + justifyContent: 'center' + }, + selectorFilled: { + backgroundColor: '#378FD2', + borderWidth: 0 + } + }); }); function createTouchableComponent(children, action) { @@ -61,7 +71,8 @@ function createTouchableComponent(children, action) { } function MemberListRow(props) { - const {id, displayName, username, onPress, user} = props; + const {id, displayName, username, onPress, theme, user} = props; + const style = getStyleFromTheme(theme); const RowComponent = ( @@ -115,6 +126,7 @@ MemberListRow.propTypes = { displayName: PropTypes.string.isRequired, pictureURL: PropTypes.string, username: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, onPress: PropTypes.func, selectable: PropTypes.bool, onRowSelect: PropTypes.func, diff --git a/app/navigation/routes.js b/app/navigation/routes.js index 12c005864..24bc821ab 100644 --- a/app/navigation/routes.js +++ b/app/navigation/routes.js @@ -10,6 +10,7 @@ import { LoadTeam, Login, Mfa, + MoreChannels, MoreDirectMessages, OptionsModal, RightMenuDrawer, @@ -80,6 +81,13 @@ export const Routes = { title: {id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'} } }, + MoreChannels: { + key: 'MoreChannels', + component: MoreChannels, + navigationProps: { + title: {id: 'more_channels.title', defaultMessage: 'More Channels'} + } + }, MoreDirectMessages: { key: 'MoreDirectMessages', component: MoreDirectMessages, diff --git a/app/scenes/channel_add_members/channel_add_members.js b/app/scenes/channel_add_members/channel_add_members.js index 49bd0da3f..a4630ea29 100644 --- a/app/scenes/channel_add_members/channel_add_members.js +++ b/app/scenes/channel_add_members/channel_add_members.js @@ -9,18 +9,24 @@ import { View } from 'react-native'; -import MemberList from 'app/components/member_list'; +import MemberList from 'app/components/custom_list'; +import {createMembersSections, loadingText, renderMemberRow} from 'app/utils/member_list'; import AddMemberButton from './add_member_button'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; -const style = StyleSheet.create({ - container: { - flex: 1 - } +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.centerChannelBg + } + }); }); export default class ChannelAddMembers extends PureComponent { static propTypes = { + theme: PropTypes.object.isRequired, currentChannel: PropTypes.object, currentChannelMemberCount: PropTypes.number, membersNotInChannel: PropTypes.array.isRequired, @@ -36,17 +42,17 @@ export default class ChannelAddMembers extends PureComponent { goBack: PropTypes.func.isRequired, handleAddChannelMembers: PropTypes.func.isRequired }) - } + }; static navigationProps = { renderRightComponent: (props, emitter) => { return ; } - } + }; state = { selectedMembers: {} - } + }; componentWillMount() { this.props.subscribeToHeaderEvent('add_members', this.handleAddMembersPress); @@ -83,25 +89,31 @@ export default class ChannelAddMembers extends PureComponent { if (loadMoreRequestStatus !== 'started' && membersNotInChannel.length < (currentTeamMemberCount - currentChannelMemberCount - 1)) { this.props.actions.getProfilesNotInChannel(this.props.currentTeam.id, this.props.currentChannel.id, this.props.membersNotInChannel.length); } - } + }; handleRowSelect = (id) => { const selectedMembers = Object.assign({}, this.state.selectedMembers, {[id]: !this.state.selectedMembers[id]}); this.setState({ selectedMembers }); - } + }; render() { + const style = getStyleFromTheme(this.props.theme); + return ( ); diff --git a/app/scenes/channel_add_members/channel_add_members_container.js b/app/scenes/channel_add_members/channel_add_members_container.js index c4e13fc84..8ccc712e7 100644 --- a/app/scenes/channel_add_members/channel_add_members_container.js +++ b/app/scenes/channel_add_members/channel_add_members_container.js @@ -8,7 +8,7 @@ import navigationSceneConnect from '../navigationSceneConnect'; import {handleAddChannelMembers} from 'app/actions/views/channel_add_members'; import {goBack} from 'app/actions/navigation'; import {getCurrentChannel, getCurrentChannelStats} from 'service/selectors/entities/channels'; -import {getMyPreferences} from 'service/selectors/entities/preferences'; +import {getMyPreferences, getTheme} from 'service/selectors/entities/preferences'; import {getCurrentTeam, getCurrentTeamStats} from 'service/selectors/entities/teams'; import {getProfilesNotInCurrentChannel} from 'service/selectors/entities/users'; import {getTeamStats} from 'service/actions/teams'; @@ -21,6 +21,7 @@ function mapStateToProps(state) { const currentChannelMemberCount = getCurrentChannelStats(state) && getCurrentChannelStats(state).member_count; return { + theme: getTheme(state), currentChannel: getCurrentChannel(state), membersNotInChannel: getProfilesNotInCurrentChannel(state), currentTeam: getCurrentTeam(state), diff --git a/app/scenes/channel_drawer/channel_drawer.js b/app/scenes/channel_drawer/channel_drawer.js index 4eac8f498..902f64fd2 100644 --- a/app/scenes/channel_drawer/channel_drawer.js +++ b/app/scenes/channel_drawer/channel_drawer.js @@ -3,7 +3,7 @@ import React from 'react'; -import ChannelList from 'app/components/channel_list'; +import ChannelDrawerList from 'app/components/channel_drawer_list'; export default class ChannelDrawer extends React.PureComponent { static propTypes = { @@ -37,7 +37,7 @@ export default class ChannelDrawer extends React.PureComponent { } = this.props; return ( - - {isAdmin && + {isAdmin && this.renderLeaveOrDeleteChannelRow() && { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.centerChannelBg + } + }); }); class ChannelMembers extends PureComponent { static propTypes = { intl: intlShape.isRequired, + theme: PropTypes.object.isRequired, currentChannel: PropTypes.object, currentChannelMembers: PropTypes.array.isRequired, currentChannelMemberCount: PropTypes.number.isRequired, @@ -38,7 +45,7 @@ class ChannelMembers extends PureComponent { goBack: PropTypes.func.isRequired, handleRemoveChannelMembers: PropTypes.func.isRequired }) - } + }; static navigationProps = { renderTitleComponent: () => { @@ -47,11 +54,11 @@ class ChannelMembers extends PureComponent { renderRightComponent: (props, emitter) => { return ; } - } + }; state = { selectedMembers: {} - } + }; componentWillMount() { this.props.subscribeToHeaderEvent('remove_members', this.handleRemoveMembersPress); @@ -112,38 +119,46 @@ class ChannelMembers extends PureComponent { onPress: () => this.removeMembers(membersToRemove) }] ); - } + }; removeMembers = (membersToRemove) => { const {actions, currentTeam, currentChannel} = this.props; actions.handleRemoveChannelMembers(currentTeam.id, currentChannel.id, membersToRemove).then(() => { actions.goBack(); }); - } + }; loadMoreMembers = () => { if (this.props.requestStatus !== 'started' && this.props.currentChannelMembers.length < this.props.currentChannelMemberCount) { this.props.actions.getProfilesInChannel(this.props.currentTeam.id, this.props.currentChannel.id, this.props.currentChannelMembers.length); } - } + }; handleRowSelect = (id) => { const selectedMembers = Object.assign({}, this.state.selectedMembers, {[id]: !this.state.selectedMembers[id]}); this.setState({ selectedMembers }); - } + }; render() { + const {currentChannel, isAdmin, theme} = this.props; + const canManage = (isAdmin && currentChannel.type !== Constants.DM_CHANNEL && currentChannel.name !== Constants.DEFAULT_CHANNEL); + const style = getStyleFromTheme(theme); + return ( ); diff --git a/app/scenes/channel_members/channel_members_container.js b/app/scenes/channel_members/channel_members_container.js index 2880aeca2..62151443f 100644 --- a/app/scenes/channel_members/channel_members_container.js +++ b/app/scenes/channel_members/channel_members_container.js @@ -8,7 +8,7 @@ import navigationSceneConnect from '../navigationSceneConnect'; import {goBack} from 'app/actions/navigation'; import {handleRemoveChannelMembers} from 'app/actions/views/channel_members'; import {getCurrentChannel, getCurrentChannelStats} from 'service/selectors/entities/channels'; -import {getMyPreferences} from 'service/selectors/entities/preferences'; +import {getMyPreferences, getTheme} from 'service/selectors/entities/preferences'; import {getCurrentTeam} from 'service/selectors/entities/teams'; import {getProfilesInCurrentChannel, getCurrentUserRoles} from 'service/selectors/entities/users'; import {getProfilesInChannel} from 'service/actions/users'; @@ -22,6 +22,7 @@ function mapStateToProps(state) { const isAdmin = currentUserRoles.includes('_admin'); return { + theme: getTheme(state), currentChannel: getCurrentChannel(state), currentChannelMembers: getProfilesInCurrentChannel(state), currentChannelMemberCount, diff --git a/app/scenes/channel_members/channel_members_title.js b/app/scenes/channel_members/channel_members_title.js index 6ee665d02..2d41f16a3 100644 --- a/app/scenes/channel_members/channel_members_title.js +++ b/app/scenes/channel_members/channel_members_title.js @@ -8,16 +8,20 @@ import { } from 'react-native'; import {getCurrentUserRoles} from 'service/selectors/entities/users'; +import {getCurrentChannel} from 'service/selectors/entities/channels'; import {getTheme} from 'service/selectors/entities/preferences'; +import {Constants} from 'service/constants'; import FormattedText from 'app/components/formatted_text'; function ChannelMemberTitle(props) { + const {currentChannel, isAdmin} = props; + const manage = (isAdmin && currentChannel.type !== Constants.DM_CHANNEL && currentChannel.name !== Constants.DEFAULT_CHANNEL); return ( @@ -26,6 +30,7 @@ function ChannelMemberTitle(props) { ChannelMemberTitle.propTypes = { isAdmin: PropTypes.bool, + currentChannel: PropTypes.object.isRequired, theme: PropTypes.object }; @@ -40,6 +45,7 @@ function mapStateToProps(state) { return { isAdmin, + currentChannel: getCurrentChannel(state), theme: getTheme(state) }; } diff --git a/app/scenes/channel_members/remove_member_button.js b/app/scenes/channel_members/remove_member_button.js index e372c0604..2ed671eb7 100644 --- a/app/scenes/channel_members/remove_member_button.js +++ b/app/scenes/channel_members/remove_member_button.js @@ -11,10 +11,13 @@ import { import FormattedText from 'app/components/formatted_text'; import {getCurrentUserRoles} from 'service/selectors/entities/users'; +import {getCurrentChannel} from 'service/selectors/entities/channels'; import {getTheme} from 'service/selectors/entities/preferences'; +import {Constants} from 'service/constants'; function RemoveMemberButton(props) { - if (!props.isAdmin) { + const {currentChannel, isAdmin} = props; + if (!isAdmin || currentChannel.type === Constants.DM_CHANNEL || currentChannel.name === Constants.DEFAULT_CHANNEL) { return null; } @@ -37,6 +40,7 @@ function RemoveMemberButton(props) { RemoveMemberButton.propTypes = { emitter: PropTypes.func.isRequired, isAdmin: PropTypes.bool.isRequired, + currentChannel: PropTypes.object.isRequired, theme: PropTypes.object }; @@ -50,6 +54,7 @@ function mapStateToProps(state) { const isAdmin = currentUserRoles.includes('_admin'); return { isAdmin, + currentChannel: getCurrentChannel(state), theme: getTheme(state) }; } diff --git a/app/scenes/index.js b/app/scenes/index.js index 3a8b62e6a..a6bc7347a 100644 --- a/app/scenes/index.js +++ b/app/scenes/index.js @@ -9,6 +9,7 @@ import ChannelAddMembers from './channel_add_members'; import LoadTeam from './load_team'; import Login from './login/login_container.js'; import Mfa from './mfa'; +import MoreChannels from './more_channels'; import MoreDirectMessages from './more_dms'; import OptionsModal from './options_modal'; import RightMenuDrawer from './right_menu_drawer'; @@ -27,6 +28,7 @@ module.exports = { LoadTeam, Login, Mfa, + MoreChannels, MoreDirectMessages, OptionsModal, RightMenuDrawer, diff --git a/app/scenes/more_channels/index.js b/app/scenes/more_channels/index.js new file mode 100644 index 000000000..0e12b9901 --- /dev/null +++ b/app/scenes/more_channels/index.js @@ -0,0 +1,42 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; + +import navigationSceneConnect from '../navigationSceneConnect'; + +import {goBack} from 'app/actions/navigation'; +import {getTheme} from 'service/selectors/entities/preferences'; +import {getMoreChannels as getMoreChannelsSelector} from 'service/selectors/entities/channels'; +import {handleSelectChannel} from 'app/actions/views/channel'; +import {getMoreChannels, joinChannel, searchMoreChannels} from 'service/actions/channels'; + +import MoreChannels from './more_channels'; + +function mapStateToProps(state) { + const {currentId: currentUserId} = state.entities.users; + const {currentId: currentTeamId} = state.entities.teams; + const {getMoreChannels: requestStatus} = state.requests.channels; + + return { + currentUserId, + currentTeamId, + channels: getMoreChannelsSelector(state), + theme: getTheme(state), + requestStatus + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + goBack, + handleSelectChannel, + joinChannel, + getMoreChannels, + searchMoreChannels + }, dispatch) + }; +} + +export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(MoreChannels); diff --git a/app/scenes/more_channels/more_channels.js b/app/scenes/more_channels/more_channels.js new file mode 100644 index 000000000..232b8ef70 --- /dev/null +++ b/app/scenes/more_channels/more_channels.js @@ -0,0 +1,242 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import { + InteractionManager, + StyleSheet, + TouchableOpacity, + View +} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import ChannelList from 'app/components/custom_list'; +import ChannelListRow from 'app/components/custom_list/channel_list_row'; +import SearchBar from 'app/components/search_bar'; + +import {Constants, RequestStatus} from 'service/constants'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.centerChannelBg + } + }); +}); + +class MoreChannels extends PureComponent { + static propTypes = { + intl: intlShape.isRequired, + currentUserId: PropTypes.string.isRequired, + currentTeamId: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + channels: PropTypes.array, + requestStatus: PropTypes.object.isRequired, + subscribeToHeaderEvent: React.PropTypes.func.isRequired, + unsubscribeFromHeaderEvent: React.PropTypes.func.isRequired, + actions: PropTypes.shape({ + goBack: PropTypes.func.isRequired, + handleSelectChannel: PropTypes.func.isRequired, + joinChannel: PropTypes.func.isRequired, + getMoreChannels: PropTypes.func.isRequired, + searchMoreChannels: PropTypes.func.isRequired + }).isRequired + }; + + static navigationProps = { + renderLeftComponent: (props, emitter, theme) => { + return ( + emitter('close')} + > + + + ); + } + }; + + constructor(props) { + super(props); + + this.searchTimeoutId = 0; + + this.state = { + channels: [], + page: 0, + next: true, + searching: false + }; + } + + componentWillMount() { + this.props.subscribeToHeaderEvent('close', this.props.actions.goBack); + } + + componentWillReceiveProps(nextProps) { + const {requestStatus} = this.props; + if (this.state.searching && + nextProps.requestStatus.status === RequestStatus.SUCCESS) { + const channels = this.filterChannels(nextProps.channels, this.state.term); + this.setState({channels}); + } else if (requestStatus.status === RequestStatus.STARTED && + nextProps.requestStatus.status === RequestStatus.SUCCESS) { + const {page} = this.state; + const channels = nextProps.channels.splice(0, (page + 1) * Constants.CHANNELS_CHUNK_SIZE); + this.setState({channels}); + } + } + + componentDidMount() { + InteractionManager.runAfterInteractions(() => { + this.props.actions.getMoreChannels(this.props.currentTeamId, 0); + }); + } + + componentWillUnmount() { + this.props.unsubscribeFromHeaderEvent('close'); + } + + filterChannels = (channels, term) => { + return channels.filter((c) => { + return (c.name.toLowerCase().indexOf(term) !== -1 || c.display_name.toLowerCase().indexOf(term) !== -1); + }); + }; + + searchBarRef = (ref) => { + this.searchBar = ref; + }; + + onSearchButtonPress = () => { + this.searchBar.blur(); + }; + + searchProfiles = (event) => { + const term = event.nativeEvent.text.toLowerCase(); + + if (term) { + const channels = this.filterChannels(this.state.channels, term); + this.setState({channels, term, searching: true}); + clearTimeout(this.searchTimeoutId); + + this.searchTimeoutId = setTimeout(() => { + this.props.actions.searchMoreChannels(this.props.currentTeamId, term); + }, Constants.SEARCH_TIMEOUT_MILLISECONDS); + } else { + this.cancelSearch(); + } + }; + + cancelSearch = () => { + this.props.actions.getMoreChannels(this.props.currentTeamId, 0); + this.setState({ + term: null, + searching: false, + page: 0 + }); + }; + + loadMoreChannels = () => { + let {page} = this.state; + if (this.props.requestStatus.status !== RequestStatus.STARTED && this.state.next && !this.state.searching) { + page = page + 1; + this.props.actions.getMoreChannels( + this.props.currentTeamId, + page * Constants.CHANNELS_CHUNK_SIZE, + Constants.CHANNELS_CHUNK_SIZE). + then((data) => { + if (Object.keys(data).length) { + this.setState({ + page + }); + } else { + this.setState({next: false}); + } + }); + } + }; + + renderChannelRow = (channel, sectionId, rowId, preferences, theme, selectable, onPress, onSelect) => { + const {id, display_name: displayName, purpose} = channel; + let onRowSelect = null; + if (selectable) { + onRowSelect = () => onSelect(sectionId, rowId); + } + + return ( + + ); + }; + + onSelectChannel = async (id) => { + this.searchBar.blur(); + await this.props.actions.joinChannel( + this.props.currentUserId, + this.props.currentTeamId, + id); + await this.props.actions.handleSelectChannel(id); + + InteractionManager.runAfterInteractions(() => { + this.props.actions.goBack(); + }); + }; + + render() { + const {formatMessage} = this.props.intl; + const isLoading = this.props.requestStatus.status === RequestStatus.STARTED; + const style = getStyleFromTheme(this.props.theme); + const more = this.state.searching ? () => true : this.loadMoreChannels; + + return ( + + + + + + + ); + } +} + +export default injectIntl(MoreChannels); diff --git a/app/scenes/more_dms/more_dms.js b/app/scenes/more_dms/more_dms.js index c8f078664..adfa7e981 100644 --- a/app/scenes/more_dms/more_dms.js +++ b/app/scenes/more_dms/more_dms.js @@ -11,17 +11,19 @@ import { } from 'react-native'; import FormattedText from 'app/components/formatted_text'; -import MemberList from 'app/components/member_list'; +import MemberList from 'app/components/custom_list'; import SearchBar from 'app/components/search_bar'; - +import {createMembersSections, loadingText, renderMemberRow} from 'app/utils/member_list'; import {Constants, RequestStatus} from 'service/constants'; -import {changeOpacity} from 'app/utils/theme'; +import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme'; -const style = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff' - } +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.centerChannelBg + } + }); }); class MoreDirectMessages extends PureComponent { @@ -100,6 +102,14 @@ class MoreDirectMessages extends PureComponent { this.props.unsubscribeFromHeaderEvent('close'); } + searchBarRef = (ref) => { + this.searchBar = ref; + }; + + onSearchButtonPress = () => { + this.searchBar.blur(); + }; + searchProfiles = (event) => { const term = event.nativeEvent.text; @@ -116,10 +126,10 @@ class MoreDirectMessages extends PureComponent { }; cancelSearch = () => { + this.props.actions.getProfiles(0); this.setState({ searching: false, - page: 0, - profiles: [] + page: 0 }); }; @@ -140,13 +150,12 @@ class MoreDirectMessages extends PureComponent { } }; - onSelectMember = (id) => { + onSelectMember = async (id) => { this.searchBar.blur(); - this.props.actions.makeDirectChannel(id). - then(() => { - InteractionManager.runAfterInteractions(() => { - this.props.actions.goBack(); - }); + await this.props.actions.makeDirectChannel(id); + + InteractionManager.runAfterInteractions(() => { + this.props.actions.goBack(); }); }; @@ -154,7 +163,7 @@ class MoreDirectMessages extends PureComponent { const {formatMessage} = this.props.intl; const isLoading = (this.props.requestStatus.status === RequestStatus.STARTED) || (this.props.searchRequest.status === RequestStatus.STARTED); - + const style = getStyleFromTheme(this.props.theme); const more = this.state.searching ? () => true : this.loadMoreProfiles; return ( @@ -163,30 +172,31 @@ class MoreDirectMessages extends PureComponent { style={{marginVertical: 5}} > { - this.searchBar = ref; - }} + ref={this.searchBarRef} placeholder={formatMessage({id: 'search_bar.search', defaultMesage: 'Search'})} height={27} fontSize={14} + textColor={this.props.theme.centerChannelColor} hideBackground={true} - textFieldBackgroundColor={changeOpacity(this.props.theme.centerChannelColor, 0.4)} + textFieldBackgroundColor={changeOpacity(this.props.theme.centerChannelColor, 0.07)} onChange={this.searchProfiles} - onSearchButtonPress={() => { - this.searchBar.blur(); - }} + onSearchButtonPress={this.onSearchButtonPress} onCancelButtonPress={this.cancelSearch} /> ); diff --git a/app/utils/member_list.js b/app/utils/member_list.js new file mode 100644 index 000000000..005f99afa --- /dev/null +++ b/app/utils/member_list.js @@ -0,0 +1,50 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import MemberListRow from 'app/components/custom_list/member_list_row'; +import {displayUsername} from 'service/utils/user_utils'; + +export const loadingText = { + id: 'mobile.loading_members', + defaultMessage: 'Loading Members...' +}; + +export function createMembersSections(data) { + const sections = {}; + data.forEach((d) => { + const name = d.username; + const sectionKey = name.substring(0, 1).toUpperCase(); + + if (!sections[sectionKey]) { + sections[sectionKey] = []; + } + + sections[sectionKey].push(d); + }); + + return sections; +} + +export function renderMemberRow(user, sectionId, rowId, preferences, theme, selectable, onPress, onSelect) { + const {id, username} = user; + const displayName = displayUsername(user, preferences); + let onRowSelect = null; + if (selectable) { + onRowSelect = () => onSelect(sectionId, rowId); + } + + return ( + + ); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index a773b1ff5..2f6d8cbf6 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1500,10 +1500,11 @@ "mobile.channel_list.privateChannel": "Private Channel", "mobile.channel_list.publicChannel": "Public Channel", "mobile.components.channels_list_view.yourChannels": "Your channels:", - "mobile.components.member_list.loading_members": "Loading Members...", "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", "mobile.components.select_server_view.continue": "Continue", "mobile.components.select_server_view.siteUrlPlaceholder": "https://mattermost.example.com", + "mobile.loading_channels": "Loading Channels...", + "mobile.loading_members": "Loading Members...", "mobile.routes.channels": "Channels", "mobile.routes.enterServerUrl": "Enter Server URL", "mobile.routes.login": "Login", diff --git a/service/actions/channels.js b/service/actions/channels.js index b862922cc..3b6bae838 100644 --- a/service/actions/channels.js +++ b/service/actions/channels.js @@ -439,6 +439,33 @@ export function getMoreChannels(teamId, offset, limit = Constants.CHANNELS_CHUNK let channels; try { channels = await Client.getMoreChannels(teamId, offset, limit); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch({type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, getState); + return null; + } + + dispatch(batchActions([ + { + type: ChannelTypes.RECEIVED_MORE_CHANNELS, + data: await channels + }, + { + type: ChannelTypes.MORE_CHANNELS_SUCCESS + } + ]), getState); + + return channels; + }; +} + +export function searchMoreChannels(teamId, term) { + return async (dispatch, getState) => { + dispatch({type: ChannelTypes.MORE_CHANNELS_REQUEST}, getState); + + let channels; + try { + channels = await Client.searchMoreChannels(teamId, term); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, getState); @@ -640,6 +667,7 @@ export default { deleteChannel, viewChannel, getMoreChannels, + searchMoreChannels, getChannelStats, addChannelMember, removeChannelMember, diff --git a/service/client/client.js b/service/client/client.js index 4a29dbe91..fcc4e4976 100644 --- a/service/client/client.js +++ b/service/client/client.js @@ -539,6 +539,13 @@ export default class Client { ); }; + searchMoreChannels = async (teamId, term) => { + return this.doFetch( + `${this.getChannelsRoute(teamId)}/more/search`, + {method: 'post', body: JSON.stringify({term})} + ); + }; + getChannelStats = async (teamId, channelId) => { return this.doFetch( `${this.getChannelNeededRoute(teamId, channelId)}/stats`, diff --git a/service/selectors/entities/channels.js b/service/selectors/entities/channels.js index 1661b620b..bc550f1c6 100644 --- a/service/selectors/entities/channels.js +++ b/service/selectors/entities/channels.js @@ -3,7 +3,7 @@ import {createSelector} from 'reselect'; import {getCurrentTeamId} from 'service/selectors/entities/teams'; -import {buildDisplayableChannelList, completeDirectChannelInfo} from 'service/utils/channel_utils'; +import {buildDisplayableChannelList, getNotMemberChannels, completeDirectChannelInfo} from 'service/utils/channel_utils'; import {Constants} from 'service/constants'; function getAllChannels(state) { @@ -93,3 +93,11 @@ export const getDefaultChannel = createSelector( return Object.values(channels).find((c) => c.team_id === teamId && c.name === Constants.DEFAULT_CHANNEL); } ); + +export const getMoreChannels = createSelector( + getAllChannels, + getChannelMemberships, + (allChannels, myMembers) => { + return getNotMemberChannels(Object.values(allChannels), myMembers); + } +); diff --git a/service/utils/channel_utils.js b/service/utils/channel_utils.js index f1b8cd435..9ac7d9731 100644 --- a/service/utils/channel_utils.js +++ b/service/utils/channel_utils.js @@ -44,6 +44,10 @@ export function buildDisplayableChannelList(usersState, teamsState, allChannels, }; } +export function getNotMemberChannels(allChannels, myMembers) { + return allChannels.filter(not(isNotMemberOf.bind(this, myMembers))); +} + export function getDirectChannelName(id, otherId) { let handle; @@ -117,6 +121,10 @@ function isDirectChannelForUser(userId, otherUserId, channel) { return channel.type === Constants.DM_CHANNEL && getUserIdFromChannelName(userId, channel.name) === otherUserId; } +function isNotMemberOf(myMembers, channel) { + return myMembers[channel.id]; +} + export function getUserIdFromChannelName(userId, channelName) { const ids = channelName.split('__'); let otherUserId = '';