From 4cdc4b21a405c5f9ceb4f1abbd5f8c58b7c35942 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Mon, 30 Jan 2017 14:38:31 -0800 Subject: [PATCH] Channel members (#189) * Channel members Add scene Add navigation Add selector Add channel member row Move list view to component for reusability Add user profile picture Add list paging Add onRowPress Add section headers * Review feedback --- app/actions/navigation/index.js | 9 ++ app/components/member_list/index.js | 137 ++++++++++++++++++ app/components/member_list/member_list_row.js | 135 +++++++++++++++++ app/navigation/routes.js | 5 + app/scenes/channel_info/channel_info.js | 5 +- .../channel_info/channel_info_container.js | 4 +- app/scenes/channel_members/channel_members.js | 58 ++++++++ .../channel_members_container.js | 35 +++++ app/scenes/channel_members/index.js | 6 + app/scenes/index.js | 2 + service/client/client.js | 4 + service/selectors/entities/users.js | 63 +++++++- 12 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 app/components/member_list/index.js create mode 100644 app/components/member_list/member_list_row.js create mode 100644 app/scenes/channel_members/channel_members.js create mode 100644 app/scenes/channel_members/channel_members_container.js create mode 100644 app/scenes/channel_members/index.js 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; + } +);