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
This commit is contained in:
Chris Duarte 2017-01-30 14:38:31 -08:00 committed by enahum
parent 36cacabeb7
commit 4cdc4b21a4
12 changed files with 457 additions and 6 deletions

View file

@ -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({

View file

@ -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 (
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{sectionId}</Text>
</View>
);
}
renderRow = (data) => {
const {id, username, status} = data;
const displayName = displayUsername(data, this.props.preferences);
const pictureURL = Client.getProfilePictureUrl(data.id);
return (
<MemberListRow
id={id}
pictureURL={pictureURL}
displayName={displayName}
username={username}
status={status}
onPress={this.props.onRowPress}
/>
);
}
renderSeparator(sectionId, rowId) {
return (
<View
key={`${sectionId}-${rowId}`}
style={style.separator}
/>
);
}
render() {
return (
<ListView
style={style.listView}
dataSource={this.state.dataSource}
renderRow={this.renderRow}
renderSectionHeader={this.renderSectionHeader}
renderSeparator={this.renderSeparator}
enableEmptySections={true}
onEndReached={this.props.onListEndReached}
onEndReachedThreshold={this.props.onListEndReachedThreshold}
/>
);
}
}

View file

@ -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 (
<TouchableHighlight onPress={action}>
{children}
</TouchableHighlight>
);
}
function MemberListRow(props) {
const {id, displayName, pictureURL, username, status, onPress} = props;
const statusToIcon = {
away: 'minus',
online: 'check'
};
let StatusComponent = null;
if (statusToIcon[status]) {
StatusComponent = (
<Icon
name={statusToIcon[status]}
size={10}
color='#fff'
/>
);
}
const RowComponent = (
<View style={style.container}>
<View style={style.avatarContainer}>
<Image
style={style.avatar}
source={{uri: pictureURL}}
/>
<View style={[style.statusContainer, style[status]]}>
{StatusComponent}
</View>
</View>
<View style={style.textContainer}>
<Text style={style.displayName}>
{displayName}
</Text>
<Text style={style.username}>
{`(@${username})`}
</Text>
</View>
</View>
);
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;

View file

@ -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

View file

@ -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 {
<View style={[style.separator, {backgroundColor: this.props.theme.centerChannelBg}]}/>
</View>
<ChannelInfoRow
action={() => true}
action={this.props.actions.goToChannelMembers}
defaultMessage='Manage Members'
detail={currentChannelMemberCount}
icon='users'

View file

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

View file

@ -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 (
<View style={style.container}>
<MemberList
members={this.props.currentChannelMembers}
onListEndReached={this.loadMoreMembers}
preferences={this.props.preferences}
/>
</View>
);
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -342,6 +342,10 @@ export default class Client {
);
};
getProfilePictureUrl = (userId) => {
return `${this.getUsersRoute()}/${userId}/image`;
};
// Team routes
createTeam = async (team) => {

View file

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