diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js
index 93f6c633a..a34ed7d7a 100644
--- a/app/actions/navigation/index.js
+++ b/app/actions/navigation/index.js
@@ -65,6 +65,15 @@ export function goToChannelInfo() {
};
}
+export function goToChannelMembers() {
+ return async (dispatch, getState) => {
+ dispatch({
+ type: NavigationTypes.NAVIGATION_PUSH,
+ route: Routes.ChannelMembers
+ }, getState);
+ };
+}
+
export function openChannelDrawer() {
return async (dispatch, getState) => {
dispatch({
diff --git a/app/components/member_list/index.js b/app/components/member_list/index.js
new file mode 100644
index 000000000..cf3df6ae1
--- /dev/null
+++ b/app/components/member_list/index.js
@@ -0,0 +1,137 @@
+// 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 Client from 'service/client';
+import {displayUsername} from 'service/utils/user_utils';
+
+import MemberListRow from './member_list_row';
+
+const style = StyleSheet.create({
+ listView: {
+ flex: 1
+ },
+ sectionContainer: {
+ backgroundColor: '#eaeaea',
+ paddingLeft: 10,
+ paddingVertical: 2
+ },
+ sectionText: {
+ fontWeight: '600'
+ },
+ separator: {
+ height: 1,
+ flex: 1,
+ backgroundColor: '#eaeaea'
+ }
+});
+
+export default class MemberList extends PureComponent {
+ static propTypes = {
+ members: PropTypes.array.isRequired,
+ onRowPress: PropTypes.func,
+ onListEndReached: PropTypes.func,
+ onListEndReachedThreshold: PropTypes.number,
+ sections: PropTypes.bool,
+ preferences: PropTypes.object
+ }
+
+ static defaultProps = {
+ onListEndReached: () => true,
+ onListEndThreshold: 10,
+ sections: true
+ }
+
+ constructor(props) {
+ super(props);
+
+ const ds = new ListView.DataSource({
+ rowHasChanged: (r1, r2) => r1 !== r2,
+ sectionHeaderHasChanged: (s1, s2) => s1 !== s2
+ });
+ const dataSource = props.sections ? ds.cloneWithRowsAndSections(this.createSections(props.members)) : ds.cloneWithRows(props.members);
+ this.state = {
+ 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
+ });
+ }
+
+ createSections = (data) => {
+ const sections = {};
+ data.forEach((d) => {
+ const name = displayUsername(d, this.props.preferences);
+ const sectionKey = name.substring(0, 1).toUpperCase();
+
+ if (!sections[sectionKey]) {
+ sections[sectionKey] = [];
+ }
+
+ sections[sectionKey].push(d);
+ });
+
+ return sections;
+ }
+
+ renderSectionHeader = (sectionData, sectionId) => {
+ return (
+
+ {sectionId}
+
+ );
+ }
+
+ renderRow = (data) => {
+ const {id, username, status} = data;
+ const displayName = displayUsername(data, this.props.preferences);
+ const pictureURL = Client.getProfilePictureUrl(data.id);
+
+ return (
+
+ );
+ }
+
+ renderSeparator(sectionId, rowId) {
+ return (
+
+ );
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/app/components/member_list/member_list_row.js b/app/components/member_list/member_list_row.js
new file mode 100644
index 000000000..ad9ec11e3
--- /dev/null
+++ b/app/components/member_list/member_list_row.js
@@ -0,0 +1,135 @@
+import React, {PropTypes} from 'react';
+import {
+ Image,
+ StyleSheet,
+ Text,
+ TouchableHighlight,
+ View
+} from 'react-native';
+import Icon from 'react-native-vector-icons/FontAwesome';
+
+const style = StyleSheet.create({
+ avatar: {
+ height: 40,
+ width: 40,
+ borderRadius: 20
+ },
+ avatarContainer: {
+ height: 50,
+ width: 50,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ away: {
+ backgroundColor: '#d3b141'
+ },
+ container: {
+ flexDirection: 'row',
+ padding: 10,
+ alignItems: 'center',
+ backgroundColor: '#fff'
+ },
+ displayName: {
+ fontSize: 16
+ },
+ offline: {
+ backgroundColor: 'white',
+ borderColor: '#bababa'
+ },
+ online: {
+ backgroundColor: 'green'
+ },
+ statusContainer: {
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: '#fff',
+ alignItems: 'center',
+ justifyContent: 'center',
+ position: 'absolute',
+ bottom: 5,
+ right: 5
+ },
+ textContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ marginLeft: 10
+ },
+ username: {
+ marginLeft: 5,
+ fontSize: 16,
+ opacity: 0.7
+ }
+});
+
+function createTouchableComponent(children, action) {
+ return (
+
+ {children}
+
+ );
+}
+
+function MemberListRow(props) {
+ const {id, displayName, pictureURL, username, status, onPress} = props;
+
+ const statusToIcon = {
+ away: 'minus',
+ online: 'check'
+ };
+
+ let StatusComponent = null;
+ if (statusToIcon[status]) {
+ StatusComponent = (
+
+ );
+ }
+
+ const RowComponent = (
+
+
+
+
+ {StatusComponent}
+
+
+
+
+ {displayName}
+
+
+ {`(@${username})`}
+
+
+
+ );
+
+ if (typeof onPress === 'function') {
+ return createTouchableComponent(RowComponent, () => onPress(id));
+ }
+
+ return RowComponent;
+}
+
+MemberListRow.propTypes = {
+ id: PropTypes.string.isRequired,
+ displayName: PropTypes.string.isRequired,
+ pictureURL: PropTypes.string,
+ status: PropTypes.string,
+ username: PropTypes.string.isRequired,
+ onPress: PropTypes.func
+};
+
+MemberListRow.defaultProps = {
+ status: 'offline'
+};
+
+export default MemberListRow;
diff --git a/app/navigation/routes.js b/app/navigation/routes.js
index 2ea0d45da..ba1f0ab81 100644
--- a/app/navigation/routes.js
+++ b/app/navigation/routes.js
@@ -16,6 +16,11 @@ export const Routes = {
ChannelDrawer: {
key: 'ChannelDrawer'
},
+ ChannelMembers: {
+ key: 'ChannelMembers',
+ title: {id: 'channel_header.manageMembers', defaultMessage: 'Manage Members'},
+ transition: RouteTransitions.Horizontal
+ },
ChannelView: {
key: 'ChannelView',
transition: RouteTransitions.Horizontal
diff --git a/app/scenes/channel_info/channel_info.js b/app/scenes/channel_info/channel_info.js
index ed0862551..13285097b 100644
--- a/app/scenes/channel_info/channel_info.js
+++ b/app/scenes/channel_info/channel_info.js
@@ -40,7 +40,8 @@ export default class ChannelInfo extends PureComponent {
isFavorite: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired,
actions: PropTypes.shape({
- getChannelStats: PropTypes.func.isRequired
+ getChannelStats: PropTypes.func.isRequired,
+ goToChannelMembers: PropTypes.func.isRequired
})
}
@@ -91,7 +92,7 @@ export default class ChannelInfo extends PureComponent {
true}
+ action={this.props.actions.goToChannelMembers}
defaultMessage='Manage Members'
detail={currentChannelMemberCount}
icon='users'
diff --git a/app/scenes/channel_info/channel_info_container.js b/app/scenes/channel_info/channel_info_container.js
index efd5d6aef..4b92cb698 100644
--- a/app/scenes/channel_info/channel_info_container.js
+++ b/app/scenes/channel_info/channel_info_container.js
@@ -4,6 +4,7 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
+import {goToChannelMembers} from 'app/actions/navigation';
import {getChannelStats} from 'service/actions/channels';
import {getCurrentChannel, getCurrentChannelStats, getChannelsByCategory} from 'service/selectors/entities/channels';
import {getTheme} from 'service/selectors/entities/preferences';
@@ -32,7 +33,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
- getChannelStats
+ getChannelStats,
+ goToChannelMembers
}, dispatch)
};
}
diff --git a/app/scenes/channel_members/channel_members.js b/app/scenes/channel_members/channel_members.js
new file mode 100644
index 000000000..2649de0b7
--- /dev/null
+++ b/app/scenes/channel_members/channel_members.js
@@ -0,0 +1,58 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes, PureComponent} from 'react';
+import {
+ StyleSheet,
+ View
+} from 'react-native';
+
+import MemberList from 'app/components/member_list';
+
+const style = StyleSheet.create({
+ container: {
+ flex: 1
+ }
+});
+
+export default class ChannelMembers extends PureComponent {
+ static propTypes = {
+ currentChannel: PropTypes.object,
+ currentChannelMembers: PropTypes.array.isRequired,
+ currentTeam: PropTypes.object,
+ preferences: PropTypes.object,
+ actions: PropTypes.shape({
+ getProfilesInChannel: PropTypes.func.isRequired
+ })
+ }
+
+ state = {
+ currentChannelMemberCount: 0
+ }
+
+ componentDidMount() {
+ this.props.actions.getProfilesInChannel(this.props.currentTeam.id, this.props.currentChannel.id, 0);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.setState({
+ currentChannelMemberCount: this.state.currentChannelMemberCount + nextProps.currentChannelMembers.length
+ });
+ }
+
+ loadMoreMembers = () => {
+ this.props.actions.getProfilesInChannel(this.props.currentTeam.id, this.props.currentChannel.id, this.state.currentChannelMemberCount);
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/app/scenes/channel_members/channel_members_container.js b/app/scenes/channel_members/channel_members_container.js
new file mode 100644
index 000000000..6c8030d79
--- /dev/null
+++ b/app/scenes/channel_members/channel_members_container.js
@@ -0,0 +1,35 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {bindActionCreators} from 'redux';
+import {connect} from 'react-redux';
+
+import {getCurrentChannel, getCurrentChannelStats} from 'service/selectors/entities/channels';
+import {getMyPreferences} from 'service/selectors/entities/preferences';
+import {getCurrentTeam} from 'service/selectors/entities/teams';
+import {getProfilesInCurrentChannel} from 'service/selectors/entities/users';
+import {getProfilesInChannel} from 'service/actions/users';
+
+import ChannelMembers from './channel_members';
+
+function mapStateToProps(state) {
+ const currentChannelMemberCount = getCurrentChannelStats(state) && getCurrentChannelStats(state).member_count;
+
+ return {
+ currentChannel: getCurrentChannel(state),
+ currentChannelMembers: getProfilesInCurrentChannel(state),
+ currentChannelMemberCount,
+ currentTeam: getCurrentTeam(state),
+ preferences: getMyPreferences(state)
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ getProfilesInChannel
+ }, dispatch)
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ChannelMembers);
diff --git a/app/scenes/channel_members/index.js b/app/scenes/channel_members/index.js
new file mode 100644
index 000000000..96ff122fb
--- /dev/null
+++ b/app/scenes/channel_members/index.js
@@ -0,0 +1,6 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import ChannelMembersContainer from './channel_members_container';
+
+export default ChannelMembersContainer;
diff --git a/app/scenes/index.js b/app/scenes/index.js
index c18d19676..3c276af75 100644
--- a/app/scenes/index.js
+++ b/app/scenes/index.js
@@ -4,6 +4,7 @@
import Channel from './channel';
import ChannelDrawer from './channel_drawer';
import ChannelInfo from './channel_info';
+import ChannelMembers from './channel_members';
import LoadTeam from './load_team';
import Login from './login/login_container.js';
import Mfa from './mfa';
@@ -17,6 +18,7 @@ const scenes = {
ChannelView: Channel, // Special case the name for this one to avoid ambiguity
ChannelDrawer,
ChannelInfo,
+ ChannelMembers,
LoadTeam,
Login,
Mfa,
diff --git a/service/client/client.js b/service/client/client.js
index cecfc4c96..5207f81b9 100644
--- a/service/client/client.js
+++ b/service/client/client.js
@@ -342,6 +342,10 @@ export default class Client {
);
};
+ getProfilePictureUrl = (userId) => {
+ return `${this.getUsersRoute()}/${userId}/image`;
+ };
+
// Team routes
createTeam = async (team) => {
diff --git a/service/selectors/entities/users.js b/service/selectors/entities/users.js
index 29989eb23..1922cfd81 100644
--- a/service/selectors/entities/users.js
+++ b/service/selectors/entities/users.js
@@ -3,10 +3,26 @@
import {createSelector} from 'reselect';
+import {getCurrentChannelId} from './channels';
+import {getMyPreferences} from './preferences';
+import {displayUsername} from 'service/utils/user_utils';
+
export function getCurrentUserId(state) {
return state.entities.users.currentId;
}
+export function getProfilesInChannel(state) {
+ return state.entities.users.profilesInChannel;
+}
+
+export function getUserStatuses(state) {
+ return state.entities.users.statuses;
+}
+
+export function getUser(state, id) {
+ return state.entities.users.profiles[id];
+}
+
export function getUsers(state) {
return state.entities.users.profiles;
}
@@ -19,6 +35,47 @@ export const getCurrentUser = createSelector(
}
);
-export function getUser(state, id) {
- return state.entities.users.profiles[id];
-}
+export const getProfileSetInCurrentChannel = createSelector(
+ getCurrentChannelId,
+ getProfilesInChannel,
+ (currentChannel, channelProfiles) => {
+ return channelProfiles[currentChannel];
+ }
+);
+
+export const getProfilesInCurrentChannel = createSelector(
+ getUsers,
+ getUserStatuses,
+ getProfileSetInCurrentChannel,
+ getMyPreferences,
+ (profiles, statuses, currentChannelProfileSet, preferences) => {
+ const currentProfiles = [];
+ if (typeof currentChannelProfileSet === 'undefined') {
+ return currentProfiles;
+ }
+
+ currentChannelProfileSet.forEach((p) => {
+ currentProfiles.push({
+ ...profiles[p],
+ status: statuses[p]
+ });
+ });
+
+ // We could get rid of this if server side sorting is a possibility
+ const sortedCurrentProfiles = currentProfiles.sort((a, b) => {
+ const nameA = displayUsername(a, preferences);
+ const nameB = displayUsername(b, preferences);
+
+ if (nameA.toUpperCase() < nameB.toUpperCase()) {
+ return -1;
+ }
+ if (nameA.toUpperCase() > nameB.toUpperCase()) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ return sortedCurrentProfiles;
+ }
+);