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:
parent
36cacabeb7
commit
4cdc4b21a4
12 changed files with 457 additions and 6 deletions
|
|
@ -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({
|
||||
|
|
|
|||
137
app/components/member_list/index.js
Normal file
137
app/components/member_list/index.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
135
app/components/member_list/member_list_row.js
Normal file
135
app/components/member_list/member_list_row.js
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
58
app/scenes/channel_members/channel_members.js
Normal file
58
app/scenes/channel_members/channel_members.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
app/scenes/channel_members/channel_members_container.js
Normal file
35
app/scenes/channel_members/channel_members_container.js
Normal 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);
|
||||
6
app/scenes/channel_members/index.js
Normal file
6
app/scenes/channel_members/index.js
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -342,6 +342,10 @@ export default class Client {
|
|||
);
|
||||
};
|
||||
|
||||
getProfilePictureUrl = (userId) => {
|
||||
return `${this.getUsersRoute()}/${userId}/image`;
|
||||
};
|
||||
|
||||
// Team routes
|
||||
|
||||
createTeam = async (team) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue