diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js
index a34ed7d7a..2a24a6c7d 100644
--- a/app/actions/navigation/index.js
+++ b/app/actions/navigation/index.js
@@ -74,6 +74,15 @@ export function goToChannelMembers() {
};
}
+export function goToChannelAddMembers() {
+ return async (dispatch, getState) => {
+ dispatch({
+ type: NavigationTypes.NAVIGATION_PUSH,
+ route: Routes.ChannelAddMembers
+ }, getState);
+ };
+}
+
export function openChannelDrawer() {
return async (dispatch, getState) => {
dispatch({
diff --git a/app/actions/views/channel_add_members.js b/app/actions/views/channel_add_members.js
new file mode 100644
index 000000000..0718a4eda
--- /dev/null
+++ b/app/actions/views/channel_add_members.js
@@ -0,0 +1,16 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {addChannelMember} from 'service/actions/channels';
+
+export function handleAddChannelMembers(teamId, channelId, members) {
+ return async (dispatch, getState) => {
+ try {
+ const requests = members.map((m) => dispatch(addChannelMember(teamId, channelId, m, getState)));
+
+ await Promise.all(requests);
+ } catch (error) {
+ // should be handled by global error handling
+ }
+ };
+}
diff --git a/app/components/member_list/index.js b/app/components/member_list/index.js
index 6f0acd909..66193608a 100644
--- a/app/components/member_list/index.js
+++ b/app/components/member_list/index.js
@@ -43,21 +43,26 @@ export default class MemberList extends PureComponent {
onRowPress: PropTypes.func,
onListEndReached: PropTypes.func,
onListEndReachedThreshold: PropTypes.number,
- sections: PropTypes.bool,
preferences: PropTypes.object,
loadingMembers: PropTypes.bool,
listPageSize: PropTypes.number,
listInitialSize: PropTypes.number,
- listScrollRenderAheadDistance: PropTypes.number
+ listScrollRenderAheadDistance: PropTypes.number,
+ showSections: PropTypes.bool,
+ selectable: PropTypes.bool,
+ onRowSelect: PropTypes.func,
+ renderRow: PropTypes.func
}
static defaultProps = {
onListEndReached: () => true,
onListEndThreshold: 50,
- sections: true,
listPageSize: 10,
listInitialSize: 10,
- listScrollRenderAheadDistance: 200
+ listScrollRenderAheadDistance: 200,
+ selectable: false,
+ onRowSelect: () => true,
+ showSections: true
}
constructor(props) {
@@ -67,18 +72,33 @@ export default class MemberList extends PureComponent {
rowHasChanged: (r1, r2) => r1 !== r2,
sectionHeaderHasChanged: (s1, s2) => s1 !== s2
});
- const dataSource = props.sections ? ds.cloneWithRowsAndSections(this.createSections(props.members)) : ds.cloneWithRows(props.members);
+ let data = props.members;
+ if (props.showSections) {
+ data = this.createSections(props.members);
+ }
+ const dataSource = ds.cloneWithRowsAndSections(data);
this.state = {
+ data,
dataSource
};
}
componentWillReceiveProps(nextProps) {
- const {members, sections} = nextProps;
- const dataSource = sections ? this.state.dataSource.cloneWithRowsAndSections(this.createSections(members)) : this.state.dataSource.cloneWithRows(members);
- this.setState({
- dataSource
- });
+ const {members, showSections} = nextProps;
+
+ if (members !== this.props.members || showSections !== this.props.showSections) {
+ let data = members;
+ if (showSections) {
+ data = this.createSections(members);
+ }
+
+ const mergedData = Object.assign({}, data, this.state.data);
+ const dataSource = showSections ? this.state.dataSource.cloneWithRowsAndSections(mergedData) : this.state.dataSource.cloneWithRows(mergedData);
+ this.setState({
+ data: mergedData,
+ dataSource
+ });
+ }
}
createSections = (data) => {
@@ -97,11 +117,23 @@ export default class MemberList extends PureComponent {
return sections;
}
- renderSectionHeader = (sectionData, sectionId) => {
- if (!this.props.sections) {
- return null;
- }
+ 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));
+ }
+
+ renderSectionHeader = (sectionData, sectionId) => {
return (
{sectionId}
@@ -109,9 +141,13 @@ export default class MemberList extends PureComponent {
);
}
- renderRow = (user) => {
+ 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 (
);
}
@@ -152,11 +191,13 @@ export default class MemberList extends PureComponent {
}
render() {
+ const renderRow = this.props.renderRow || this.renderRow;
+
return (
+
{children}
-
+
);
}
@@ -43,17 +65,40 @@ function MemberListRow(props) {
const RowComponent = (
+ {props.selectable &&
+
+
+
+ {props.selected &&
+
+ }
+
+
+
+ }
-
- {displayName}
-
-
- {`(@${username})`}
-
+
+
+ {displayName}
+
+
+
+
+ {`(@${username})`}
+
+
);
@@ -70,7 +115,10 @@ MemberListRow.propTypes = {
displayName: PropTypes.string.isRequired,
pictureURL: PropTypes.string,
username: PropTypes.string.isRequired,
- onPress: PropTypes.func
+ onPress: PropTypes.func,
+ selectable: PropTypes.bool,
+ onRowSelect: PropTypes.func,
+ selected: PropTypes.bool
};
export default MemberListRow;
diff --git a/app/navigation/routes.js b/app/navigation/routes.js
index 2f79e5b98..09189704d 100644
--- a/app/navigation/routes.js
+++ b/app/navigation/routes.js
@@ -6,6 +6,7 @@ import {
ChannelDrawer,
ChannelInfo,
ChannelMembers,
+ ChannelAddMembers,
LoadTeam,
Login,
Mfa,
@@ -43,6 +44,14 @@ export const Routes = {
title: {id: 'channel_header.manageMembers', defaultMessage: 'Manage Members'}
}
},
+ ChannelAddMembers: {
+ key: 'ChannelAddMembers',
+ transition: RouteTransitions.Horizontal,
+ component: ChannelAddMembers,
+ navigationProps: {
+ title: {id: 'channel_header.addMembers', defaultMessage: 'Add Members'}
+ }
+ },
ChannelView: {
key: 'ChannelView',
transition: RouteTransitions.Horizontal,
diff --git a/app/scenes/channel_add_members/add_member_button.js b/app/scenes/channel_add_members/add_member_button.js
new file mode 100644
index 000000000..b8af71c10
--- /dev/null
+++ b/app/scenes/channel_add_members/add_member_button.js
@@ -0,0 +1,47 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes} from 'react';
+import {connect} from 'react-redux';
+import {
+ TouchableOpacity,
+ View
+} from 'react-native';
+
+import FormattedText from 'app/components/formatted_text';
+
+import {getTheme} from 'service/selectors/entities/preferences';
+
+function AddMemberButton(props) {
+ return (
+
+ props.emitter('add_members')}
+ style={{paddingHorizontal: 15}}
+ >
+
+
+
+ );
+}
+
+AddMemberButton.propTypes = {
+ emitter: PropTypes.func.isRequired,
+ theme: PropTypes.object
+};
+
+AddMemberButton.defaultProps = {
+ theme: {}
+};
+
+function mapStateToProps(state) {
+ return {
+ theme: getTheme(state)
+ };
+}
+
+export default connect(mapStateToProps)(AddMemberButton);
diff --git a/app/scenes/channel_add_members/channel_add_members.js b/app/scenes/channel_add_members/channel_add_members.js
new file mode 100644
index 000000000..8211fbc9a
--- /dev/null
+++ b/app/scenes/channel_add_members/channel_add_members.js
@@ -0,0 +1,110 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes, PureComponent} from 'react';
+import {
+ Alert,
+ InteractionManager,
+ StyleSheet,
+ View
+} from 'react-native';
+
+import MemberList from 'app/components/member_list';
+
+import AddMemberButton from './add_member_button';
+
+const style = StyleSheet.create({
+ container: {
+ flex: 1
+ }
+});
+
+export default class ChannelAddMembers extends PureComponent {
+ static propTypes = {
+ currentChannel: PropTypes.object,
+ currentChannelMemberCount: PropTypes.number,
+ membersNotInChannel: PropTypes.array.isRequired,
+ currentTeam: PropTypes.object,
+ currentTeamMemberCount: PropTypes.number,
+ preferences: PropTypes.object,
+ loadMoreRequestStatus: PropTypes.string,
+ subscribeToHeaderEvent: React.PropTypes.func,
+ unsubscribeFromHeaderEvent: React.PropTypes.func,
+ actions: PropTypes.shape({
+ getTeamStats: PropTypes.func.isRequired,
+ getProfilesNotInChannel: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ handleAddChannelMembers: PropTypes.func.isRequired
+ })
+ }
+
+ static navigationProps = {
+ renderRightComponent: (props, emitter) => {
+ return ;
+ }
+ }
+
+ state = {
+ selectedMembers: {}
+ }
+
+ componentWillMount() {
+ this.props.subscribeToHeaderEvent('add_members', this.handleAddMembersPress);
+ }
+
+ componentDidMount() {
+ InteractionManager.runAfterInteractions(() => {
+ this.props.actions.getProfilesNotInChannel(this.props.currentTeam.id, this.props.currentChannel.id, 0);
+ this.props.actions.getTeamStats(this.props.currentTeam.id);
+ });
+ }
+
+ componentWillUnmount() {
+ this.props.unsubscribeFromHeaderEvent('add_members');
+ }
+
+ handleAddMembersPress = () => {
+ const {selectedMembers} = this.state;
+ const {actions, currentTeam, currentChannel} = this.props;
+ const membersToAdd = Object.keys(selectedMembers).filter((m) => selectedMembers[m]);
+
+ if (!membersToAdd.length) {
+ Alert.alert('Add Members', 'You must select at least one member to add to the channel.');
+ return;
+ }
+
+ actions.handleAddChannelMembers(currentTeam.id, currentChannel.id, membersToAdd).then(() => {
+ actions.goBack();
+ });
+ }
+
+ loadMoreMembers = () => {
+ const {currentChannelMemberCount, currentTeamMemberCount, membersNotInChannel, loadMoreRequestStatus} = this.props;
+ 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() {
+ 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
new file mode 100644
index 000000000..c4e13fc84
--- /dev/null
+++ b/app/scenes/channel_add_members/channel_add_members_container.js
@@ -0,0 +1,46 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {bindActionCreators} from 'redux';
+
+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 {getCurrentTeam, getCurrentTeamStats} from 'service/selectors/entities/teams';
+import {getProfilesNotInCurrentChannel} from 'service/selectors/entities/users';
+import {getTeamStats} from 'service/actions/teams';
+import {getProfilesNotInChannel} from 'service/actions/users';
+
+import ChannelAddMembers from './channel_add_members';
+
+function mapStateToProps(state) {
+ const currentTeamMemberCount = getCurrentTeamStats(state) && getCurrentTeamStats(state).total_member_count;
+ const currentChannelMemberCount = getCurrentChannelStats(state) && getCurrentChannelStats(state).member_count;
+
+ return {
+ currentChannel: getCurrentChannel(state),
+ membersNotInChannel: getProfilesNotInCurrentChannel(state),
+ currentTeam: getCurrentTeam(state),
+ currentTeamMemberCount,
+ currentChannelMemberCount,
+ preferences: getMyPreferences(state),
+ loadMoreRequestStatus: state.requests.users.getProfilesNotInChannel.status,
+ addChannelMemberRequestStatus: state.requests.channels.addChannelMember
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ getTeamStats,
+ getProfilesNotInChannel,
+ goBack,
+ handleAddChannelMembers
+ }, dispatch)
+ };
+}
+
+export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(ChannelAddMembers);
diff --git a/app/scenes/channel_add_members/index.js b/app/scenes/channel_add_members/index.js
new file mode 100644
index 000000000..672d255d6
--- /dev/null
+++ b/app/scenes/channel_add_members/index.js
@@ -0,0 +1,6 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import ChannelAddMembersContainer from './channel_add_members_container';
+
+export default ChannelAddMembersContainer;
diff --git a/app/scenes/channel_info/channel_info.js b/app/scenes/channel_info/channel_info.js
index 3ef3d355d..99beda72e 100644
--- a/app/scenes/channel_info/channel_info.js
+++ b/app/scenes/channel_info/channel_info.js
@@ -47,6 +47,7 @@ class ChannelInfo extends PureComponent {
actions: PropTypes.shape({
getChannelStats: PropTypes.func.isRequired,
goToChannelMembers: PropTypes.func.isRequired,
+ goToChannelAddMembers: PropTypes.func.isRequired,
markFavorite: PropTypes.func.isRequired,
unmarkFavorite: PropTypes.func.isRequired,
goBack: PropTypes.func.isRequired,
@@ -177,7 +178,7 @@ class ChannelInfo extends PureComponent {
true}
+ action={this.props.actions.goToChannelAddMembers}
defaultMessage='Add Members'
icon='user-plus'
textId='channel_header.addMembers'
diff --git a/app/scenes/channel_info/channel_info_container.js b/app/scenes/channel_info/channel_info_container.js
index a695e8a0c..e1a813852 100644
--- a/app/scenes/channel_info/channel_info_container.js
+++ b/app/scenes/channel_info/channel_info_container.js
@@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
import navigationSceneConnect from '../navigationSceneConnect';
-import {goToChannelMembers, goBack} from 'app/actions/navigation';
+import {goToChannelMembers, goToChannelAddMembers, goBack} from 'app/actions/navigation';
import {getChannelStats} from 'service/actions/channels';
import {markFavorite, unmarkFavorite, leaveChannel} from 'app/actions/views/channel';
import {getCurrentChannel, getCurrentChannelStats, getChannelsByCategory} from 'service/selectors/entities/channels';
@@ -39,6 +39,7 @@ function mapDispatchToProps(dispatch) {
actions: bindActionCreators({
getChannelStats,
goToChannelMembers,
+ goToChannelAddMembers,
markFavorite,
unmarkFavorite,
leaveChannel,
diff --git a/app/scenes/index.js b/app/scenes/index.js
index 7c195edf5..f1812bad5 100644
--- a/app/scenes/index.js
+++ b/app/scenes/index.js
@@ -5,6 +5,7 @@ import Channel from './channel';
import ChannelDrawer from './channel_drawer';
import ChannelInfo from './channel_info';
import ChannelMembers from './channel_members';
+import ChannelAddMembers from './channel_add_members';
import LoadTeam from './load_team';
import Login from './login/login_container.js';
import Mfa from './mfa';
@@ -19,6 +20,7 @@ module.exports = {
ChannelDrawer,
ChannelInfo,
ChannelMembers,
+ ChannelAddMembers,
LoadTeam,
Login,
Mfa,
diff --git a/app/scenes/navigationSceneConnect.js b/app/scenes/navigationSceneConnect.js
index fba77cde0..990e913e9 100644
--- a/app/scenes/navigationSceneConnect.js
+++ b/app/scenes/navigationSceneConnect.js
@@ -43,7 +43,7 @@ const defaults = {
);
diff --git a/service/selectors/entities/teams.js b/service/selectors/entities/teams.js
index 4812ac19c..8441573d4 100644
--- a/service/selectors/entities/teams.js
+++ b/service/selectors/entities/teams.js
@@ -13,6 +13,10 @@ export function getTeams(state) {
return state.entities.teams.teams;
}
+export function getTeamStats(state) {
+ return state.entities.teams.stats;
+}
+
export const getCurrentTeam = createSelector(
getTeams,
getCurrentTeamId,
@@ -28,3 +32,11 @@ export const getCurrentTeamUrl = createSelector(
return `${currentUrl}/${currentTeam.name}`;
}
);
+
+export const getCurrentTeamStats = createSelector(
+ getCurrentTeamId,
+ getTeamStats,
+ (currentTeamId, teamStats) => {
+ return teamStats[currentTeamId];
+ }
+);
diff --git a/service/selectors/entities/users.js b/service/selectors/entities/users.js
index 8c2861894..0ef49b6cd 100644
--- a/service/selectors/entities/users.js
+++ b/service/selectors/entities/users.js
@@ -13,6 +13,10 @@ export function getProfilesInChannel(state) {
return state.entities.users.profilesInChannel;
}
+export function getProfilesNotInChannel(state) {
+ return state.entities.users.profilesNotInChannel;
+}
+
export function getUserStatuses(state) {
return state.entities.users.statuses;
}
@@ -41,28 +45,44 @@ export const getProfileSetInCurrentChannel = createSelector(
}
);
+export const getProfileSetNotInCurrentChannel = createSelector(
+ getCurrentChannelId,
+ getProfilesNotInChannel,
+ (currentChannel, channelProfiles) => {
+ return channelProfiles[currentChannel];
+ }
+);
+
+function sortAndInjectProfiles(profiles, profileSet) {
+ const currentProfiles = [];
+ if (typeof profileSet === 'undefined') {
+ return currentProfiles;
+ }
+
+ profileSet.forEach((p) => {
+ currentProfiles.push(profiles[p]);
+ });
+
+ const sortedCurrentProfiles = currentProfiles.sort((a, b) => {
+ const nameA = a.username;
+ const nameB = b.username;
+
+ return nameA.localeCompare(nameB);
+ });
+
+ return sortedCurrentProfiles;
+}
+
export const getProfilesInCurrentChannel = createSelector(
getUsers,
getProfileSetInCurrentChannel,
- (profiles, currentChannelProfileSet) => {
- const currentProfiles = [];
- if (typeof currentChannelProfileSet === 'undefined') {
- return currentProfiles;
- }
+ (profiles, currentChannelProfileSet) => sortAndInjectProfiles(profiles, currentChannelProfileSet)
+);
- currentChannelProfileSet.forEach((p) => {
- currentProfiles.push(profiles[p]);
- });
-
- const sortedCurrentProfiles = currentProfiles.sort((a, b) => {
- const nameA = a.username;
- const nameB = b.username;
-
- return nameA.localeCompare(nameB);
- });
-
- return sortedCurrentProfiles;
- }
+export const getProfilesNotInCurrentChannel = createSelector(
+ getUsers,
+ getProfileSetNotInCurrentChannel,
+ (profiles, notInCurrentChannelProfileSet) => sortAndInjectProfiles(profiles, notInCurrentChannelProfileSet)
);
export function getStatusForUserId(state, userId) {