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; + } +);