PLT-5493 Add a profile scene that shows user information (#287)

* PLT-5493 React Native: Add a profile scene that shows user information

* Review feedback
This commit is contained in:
Chris Duarte 2017-02-24 05:17:05 -08:00 committed by enahum
parent 567310b9d6
commit b09dbbb79c
16 changed files with 514 additions and 43 deletions

View file

@ -102,6 +102,20 @@ export function goToThread(channelId, rootId) {
};
}
export function goToUserProfile(userId) {
return async (dispatch, getState) => {
dispatch({
type: NavigationTypes.NAVIGATION_PUSH,
route: {
...Routes.UserProfile,
props: {
userId
}
}
}, getState);
};
}
export function openChannelDrawer() {
return async (dispatch, getState) => {
dispatch({

View file

@ -0,0 +1,12 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {makeDirectChannel} from 'app/actions/views/more_dms';
import {NavigationTypes} from 'app/constants';
export function handleSendMessage(otherUserId) {
return async (dispatch, getState) => {
await makeDirectChannel(otherUserId)(dispatch, getState);
dispatch({type: NavigationTypes.NAVIGATION_POP_TO_INDEX, index: 0}, getState);
};
}

View file

@ -1,12 +1,13 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import React, {Component, PropTypes} from 'react';
import {
Image,
StyleSheet,
Text,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native';
@ -89,19 +90,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
});
});
export default class Post extends React.Component {
export default class Post extends Component {
static propTypes = {
style: View.propTypes.style,
post: React.PropTypes.object.isRequired,
user: React.PropTypes.object,
displayName: React.PropTypes.string,
renderReplies: React.PropTypes.bool,
isFirstReply: React.PropTypes.bool,
isLastReply: React.PropTypes.bool,
commentedOnPost: React.PropTypes.object,
commentedOnDisplayName: React.PropTypes.string,
theme: React.PropTypes.object.isRequired,
onPress: React.PropTypes.func
post: PropTypes.object.isRequired,
user: PropTypes.object,
displayName: PropTypes.string,
renderReplies: PropTypes.bool,
isFirstReply: PropTypes.bool,
isLastReply: PropTypes.bool,
commentedOnPost: PropTypes.object,
commentedOnDisplayName: PropTypes.string,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
actions: PropTypes.shape({
goToUserProfile: PropTypes.func.isRequired
}).isRequired
};
handlePress = () => {
@ -177,6 +181,10 @@ export default class Post extends React.Component {
return attachments;
}
viewUserProfile = () => {
this.props.actions.goToUserProfile(this.props.user.id);
}
render() {
const style = getStyleSheet(this.props.theme);
const PROFILE_PICTURE_SIZE = 32;
@ -199,6 +207,7 @@ export default class Post extends React.Component {
<FormattedText
id='post_info.system'
defaultMessage='System'
style={style.displayName}
/>
);
@ -217,23 +226,36 @@ export default class Post extends React.Component {
</View>
);
displayName = this.props.post.props.override_username;
displayName = (
<Text style={style.displayName}>
{this.props.post.props.override_username}
</Text>
);
messageStyle = [style.message, style.webhookMessage];
} else {
profilePicture = (
<ProfilePicture
user={this.props.user}
size={PROFILE_PICTURE_SIZE}
/>
<TouchableOpacity onPress={this.viewUserProfile}>
<ProfilePicture
user={this.props.user}
size={PROFILE_PICTURE_SIZE}
/>
</TouchableOpacity>
);
if (this.props.displayName) {
displayName = this.props.displayName;
displayName = (
<TouchableOpacity onPress={this.viewUserProfile}>
<Text style={style.displayName}>
{this.props.displayName}
</Text>
</TouchableOpacity>
);
} else {
displayName = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
style={style.displayName}
/>
);
}
@ -250,9 +272,7 @@ export default class Post extends React.Component {
</View>
<View style={style.rightColumn}>
<View style={style.postInfoContainer}>
<Text style={style.displayName}>
{displayName}
</Text>
{displayName}
<Text style={style.time}>
<FormattedTime value={this.props.post.create_at}/>
</Text>
@ -279,9 +299,7 @@ export default class Post extends React.Component {
{this.renderReplyBar(style)}
<View style={style.rightColumn}>
<View style={style.postInfoContainer}>
<Text style={style.displayName}>
{displayName}
</Text>
{displayName}
<Text style={style.time}>
<FormattedTime value={this.props.post.create_at}/>
</Text>

View file

@ -2,8 +2,10 @@
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getMyPreferences, getTheme} from 'service/selectors/entities/preferences';
import {goToUserProfile} from 'app/actions/navigation';
import {getUser} from 'service/selectors/entities/users';
import {displayUsername} from 'service/utils/user_utils';
@ -24,5 +26,12 @@ function mapStateToProps(state, ownProps) {
};
}
export default connect(mapStateToProps)(Post);
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
goToUserProfile
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Post);

View file

@ -13,24 +13,21 @@ import Client from 'service/client';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
alignItems: 'flex-end',
justifyContent: 'flex-end'
},
statusContainer: {
width: 14,
height: 14,
statusWrapper: {
position: 'absolute',
bottom: 0,
right: 0,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
borderWidth: 1.5,
borderColor: theme.centerChannelBg
},
statusContainer: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
status: {
backgroundColor: 'transparent',
color: theme.centerChannelBg
},
online: {
@ -54,6 +51,9 @@ const statusToIcon = {
export default class ProfilePicture extends React.PureComponent {
static propTypes = {
size: React.PropTypes.number,
statusBorderWidth: React.PropTypes.number,
statusSize: React.PropTypes.number,
statusIconSize: React.PropTypes.number,
user: React.PropTypes.object,
status: React.PropTypes.string,
theme: React.PropTypes.object.isRequired,
@ -63,7 +63,10 @@ export default class ProfilePicture extends React.PureComponent {
};
static defaultProps = {
size: 128
size: 128,
statusBorderWidth: 2,
statusSize: 14,
statusIconSize: 8
};
componentDidMount() {
@ -86,7 +89,7 @@ export default class ProfilePicture extends React.PureComponent {
<Icon
style={style.status}
name={statusToIcon[this.props.status]}
size={8}
size={this.props.statusIconSize}
/>
);
}
@ -99,8 +102,32 @@ export default class ProfilePicture extends React.PureComponent {
defaultSource={placeholder}
/>
{this.props.status &&
<View style={[style.statusContainer, style[this.props.status]]}>
{statusIcon}
<View
style={[
style.statusWrapper,
{
width: this.props.statusSize,
height: this.props.statusSize,
borderWidth: this.props.statusBorderWidth,
borderRadius: this.props.statusSize / 2
},
(this.props.status === 'offline' && style.offline)
]}
>
<View
style={[
style.statusContainer,
{
width: this.props.statusSize - this.props.statusBorderWidth,
height: this.props.statusSize - this.props.statusBorderWidth,
borderRadius: (this.props.statusSize - this.props.statusBorderWidth) / 2,
padding: this.props.statusBorderWidth
},
style[this.props.status
]]}
>
{statusIcon}
</View>
</View>
}
</View>

View file

@ -6,6 +6,7 @@ import keyMirror from 'service/utils/key_mirror';
const NavigationTypes = keyMirror({
NAVIGATION_PUSH: null,
NAVIGATION_POP: null,
NAVIGATION_POP_TO_INDEX: null,
NAVIGATION_OPEN_LEFT_DRAWER: null,
NAVIGATION_OPEN_RIGHT_DRAWER: null,
NAVIGATION_CLOSE_DRAWERS: null,

View file

@ -18,7 +18,8 @@ import {
Search,
SelectServer,
SelectTeam,
Thread
Thread,
UserProfile
} from 'app/scenes';
import keyMirror from 'service/utils/key_mirror';
@ -136,6 +137,14 @@ export const Routes = {
navigationProps: {
title: {id: 'mobile.routes.thread', defaultMessage: 'Thread'}
}
},
UserProfile: {
key: 'UserProfile',
transition: RouteTransitions.Horizontal,
component: UserProfile,
navigationProps: {
title: {id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}
}
}
};

View file

@ -59,6 +59,41 @@ export default function(state = initialState, action) {
return NavigationExperimental.StateUtils.pop(state);
case NavigationTypes.NAVIGATION_POP_TO_INDEX: {
let newState = {...state};
if (!newState.isModal && (newState.leftDrawerOpen || newState.rightDrawerOpen)) {
newState = {
...newState,
leftDrawerOpen: false,
rightDrawerOpen: false
};
}
if (newState.isModal) {
newState = {
...newState,
modal: {
index: 0,
routes: []
},
isModal: false
};
}
if (action.index === newState.index || action.index > newState.routes.length - 1) {
return newState;
}
const nextRoutes = newState.routes.slice(0, action.index + 1);
return {
...newState,
index: action.index,
routes: nextRoutes
};
}
case NavigationTypes.NAVIGATION_OPEN_LEFT_DRAWER:
return {
...state,

View file

@ -18,6 +18,7 @@ import Search from './search/search_container.js';
import SelectServer from './select_server/select_server_container.js';
import SelectTeam from './select_team/select_team_container.js';
import Thread from './thread';
import UserProfile from './user_profile';
module.exports = {
ChannelView: Channel, // Special case the name for this one to avoid ambiguity
@ -36,5 +37,6 @@ module.exports = {
Search,
SelectServer,
SelectTeam,
Thread
Thread,
UserProfile
};

View file

@ -18,12 +18,12 @@ const defaults = {
renderBackButton: (props, emitter, theme) => {
return (
<TouchableOpacity
style={{flexDirection: 'row', ...Platform.select({ios: {marginTop: 10}, android: {marginTop: 15}}), marginLeft: 15, alignItems: 'center'}}
style={{flexDirection: 'row', flex: 1, paddingHorizontal: 15, alignItems: 'center'}}
onPress={props.onNavigateBack}
>
<Icon
name='angle-left'
size={25}
size={18}
color={theme.sidebarHeaderTextColor}
/>
<FormattedText

View file

@ -0,0 +1,6 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import UserProfileContainer from './user_profile_container';
export default UserProfileContainer;

View file

@ -0,0 +1,150 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes, PureComponent} from 'react';
import {
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
import ProfilePicture from 'app/components/profile_picture';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {getFullName} from 'service/utils/user_utils';
import UserProfileRow from './user_profile_row';
const createStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flex: 1
},
content: {
marginBottom: 25,
marginHorizontal: 15
},
displayName: {
marginTop: 15,
color: theme.centerChannelColor,
fontSize: 17,
fontWeight: '600'
},
header: {
fontSize: 13,
fontWeight: '600',
color: changeOpacity(theme.centerChannelColor, 0.5),
marginTop: 25,
marginBottom: 10
},
scrollView: {
flex: 1,
backgroundColor: theme.centerChannelBg
},
scrollViewContent: {
flex: 1
},
text: {
fontSize: 15,
color: theme.centerChannelColor
},
top: {
padding: 25,
alignItems: 'center',
justifyContent: 'center'
},
username: {
marginTop: 15,
color: theme.centerChannelColor,
fontSize: 15
}
});
});
export default class UserProfile extends PureComponent {
static propTypes = {
currentUserId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
actions: PropTypes.shape({
handleSendMessage: PropTypes.func.isRequired
}).isRequired
};
state = {
isFavorite: false
}
getDisplayName = () => {
const {theme, user} = this.props;
const style = createStyleSheet(theme);
const displayName = getFullName(user);
if (displayName) {
return <Text style={style.displayName}>{displayName}</Text>;
}
return null;
}
buildDisplayBlock = (property) => {
const {theme, user} = this.props;
const style = createStyleSheet(theme);
if (user.hasOwnProperty(property) && user[property].length > 0) {
return (
<View>
<Text style={style.header}>{property.toUpperCase()}</Text>
<Text style={style.text}>{user[property]}</Text>
</View>
);
}
return null;
}
sendMessage = () => {
this.props.actions.handleSendMessage(this.props.user.id);
}
render() {
const {theme, user} = this.props;
const style = createStyleSheet(theme);
return (
<View style={style.container}>
<ScrollView
style={style.scrollView}
contentContainerStyle={style.scrollViewContent}
>
<View style={style.top}>
<ProfilePicture
user={user}
size={150}
statusBorderWidth={6}
statusSize={40}
statusIconSize={18}
/>
{this.getDisplayName()}
<Text style={style.username}>{`@${user.username}`}</Text>
</View>
<View style={style.content}>
{this.buildDisplayBlock('username')}
{this.buildDisplayBlock('email')}
{this.buildDisplayBlock('position')}
</View>
{this.props.currentUserId !== this.props.user.id &&
<UserProfileRow
action={this.sendMessage}
defaultMessage='Send Message'
icon='paper-plane-o'
textId='mobile.routes.user_profile.send_message'
theme={theme}
/>
}
</ScrollView>
</View>
);
}
}

View file

@ -0,0 +1,29 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {handleSendMessage} from 'app/actions/views/user_profile';
import {getTheme} from 'service/selectors/entities/preferences';
import UserProfile from './user_profile';
import navigationSceneConnect from '../navigationSceneConnect';
function mapStateToProps(state, ownProps) {
return {
currentUserId: state.entities.users.currentId,
user: state.entities.users.profiles[ownProps.userId],
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handleSendMessage
}, dispatch)
};
}
export default navigationSceneConnect(mapStateToProps, mapDispatchToProps)(UserProfile);

View file

@ -0,0 +1,130 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes} from 'react';
import {
StyleSheet,
Switch,
Text,
TouchableHighlight,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import FormattedText from 'app/components/formatted_text';
const createStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
backgroundColor: changeOpacity(theme.centerChannelBg, 0.7),
paddingHorizontal: 15,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.3),
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.3)
},
detail: {
marginHorizontal: 15,
color: 'rgba(0, 0, 0, 0.5)',
fontSize: 15
},
label: {
flex: 1,
marginLeft: 15,
fontSize: 15,
paddingVertical: 15,
color: theme.centerChannelColor
},
leftIcon: {
width: 17,
color: theme.centerChannelColor
},
rightIcon: {
color: theme.centerChannelColor,
opacity: 0.7
},
wrapper: {
backgroundColor: '#ddd'
}
});
});
function createTouchableComponent(children, action) {
return (
<TouchableHighlight onPress={action}>
{children}
</TouchableHighlight>
);
}
function userProfileRow(props) {
const {action, defaultMessage, detail, icon, textId, togglable, theme, shouldRender = true} = props;
if (!shouldRender) {
return null;
}
const style = createStyleSheet(theme);
const RowComponent = (
<View style={style.wrapper}>
<View style={style.container}>
<Icon
name={icon}
size={15}
style={style.leftIcon}
/>
<FormattedText
style={[style.label]}
id={textId}
defaultMessage={defaultMessage}
/>
<Text style={style.detail}>{detail}</Text>
{togglable ?
<Switch
onValueChange={action}
value={detail}
/> :
<Icon
name='chevron-right'
size={15}
style={style.rightIcon}
/>
}
</View>
</View>
);
if (togglable) {
return RowComponent;
}
return createTouchableComponent(RowComponent, action);
}
userProfileRow.propTypes = {
action: PropTypes.func.isRequired,
defaultMessage: PropTypes.string.isRequired,
detail: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool
]),
icon: PropTypes.string.isRequired,
iconColor: PropTypes.string,
textId: PropTypes.string.isRequired,
togglable: PropTypes.bool,
textColor: PropTypes.string,
theme: PropTypes.object.isRequired
};
userProfileRow.defaultProps = {
iconColor: 'rgba(0, 0, 0, 0.7)',
textColor: '#000',
togglable: false
};
export default userProfileRow;

View file

@ -1519,6 +1519,8 @@
"mobile.routes.channel_members.action_message": "You must select at least one member to {term} {prep} the channel.",
"mobile.routes.channel_members.action_message_confirm": "Are you sure you want to {term} the selected members {prep} the channel?",
"mobile.routes.thread": "Thread",
"mobile.routes.user_profile": "Profile",
"mobile.routes.user_profile.send_message": "Send Message",
"more_channels.close": "Close",
"more_channels.create": "Create New Channel",
"more_channels.createClick": "Click 'Create New Channel' to make a new one",

View file

@ -154,6 +154,33 @@ describe('Reducers.Navigation', () => {
});
});
it('NAVIGATION_POP_TO_INDEX', () => {
let state = initialState();
state = reduceAndFreeze(state, {
type: NavigationTypes.NAVIGATION_PUSH,
route: {key: 'fake'}
});
state = reduceAndFreeze(state, {
type: NavigationTypes.NAVIGATION_PUSH,
route: {key: 'fake2'}
});
state = reduceAndFreeze(state, {
type: NavigationTypes.NAVIGATION_PUSH,
route: {key: 'fake3'}
});
it('Reset to first index', () => {
state = reduceAndFreeze(state, {
type: NavigationTypes.NAVIGATION_POP_TO_INDEX,
index: 0
});
assert.equal(state.index, 0);
assert.deepEqual(state.routes, [Routes.Root, {key: 'fake'}]);
});
});
it('NAVIGATION_OPEN_LEFT_DRAWER', () => {
let state = initialState();