diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index d71f63636..e1985e794 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -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({ diff --git a/app/actions/views/user_profile.js b/app/actions/views/user_profile.js new file mode 100644 index 000000000..253120171 --- /dev/null +++ b/app/actions/views/user_profile.js @@ -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); + }; +} diff --git a/app/components/post/post.js b/app/components/post/post.js index 55f7636a0..8ae8221e0 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -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 { ); @@ -217,23 +226,36 @@ export default class Post extends React.Component { ); - displayName = this.props.post.props.override_username; + displayName = ( + + {this.props.post.props.override_username} + + ); messageStyle = [style.message, style.webhookMessage]; } else { profilePicture = ( - + + + ); if (this.props.displayName) { - displayName = this.props.displayName; + displayName = ( + + + {this.props.displayName} + + + ); } else { displayName = ( ); } @@ -250,9 +272,7 @@ export default class Post extends React.Component { - - {displayName} - + {displayName} @@ -279,9 +299,7 @@ export default class Post extends React.Component { {this.renderReplyBar(style)} - - {displayName} - + {displayName} diff --git a/app/components/post/post_container.js b/app/components/post/post_container.js index f0aabde87..f51fbd0e8 100644 --- a/app/components/post/post_container.js +++ b/app/components/post/post_container.js @@ -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); diff --git a/app/components/profile_picture/profile_picture.js b/app/components/profile_picture/profile_picture.js index 6d9ddb352..c070d2538 100644 --- a/app/components/profile_picture/profile_picture.js +++ b/app/components/profile_picture/profile_picture.js @@ -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 { ); } @@ -99,8 +102,32 @@ export default class ProfilePicture extends React.PureComponent { defaultSource={placeholder} /> {this.props.status && - - {statusIcon} + + + {statusIcon} + } diff --git a/app/constants/navigation.js b/app/constants/navigation.js index d0a28f2c1..0d980e2ec 100644 --- a/app/constants/navigation.js +++ b/app/constants/navigation.js @@ -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, diff --git a/app/navigation/routes.js b/app/navigation/routes.js index 24bc821ab..2b86be46a 100644 --- a/app/navigation/routes.js +++ b/app/navigation/routes.js @@ -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'} + } } }; diff --git a/app/reducers/navigation/index.js b/app/reducers/navigation/index.js index 979fd6eee..9a281a44c 100644 --- a/app/reducers/navigation/index.js +++ b/app/reducers/navigation/index.js @@ -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, diff --git a/app/scenes/index.js b/app/scenes/index.js index a6bc7347a..9946c33ad 100644 --- a/app/scenes/index.js +++ b/app/scenes/index.js @@ -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 }; diff --git a/app/scenes/navigationSceneConnect.js b/app/scenes/navigationSceneConnect.js index 485689c7f..964005fe7 100644 --- a/app/scenes/navigationSceneConnect.js +++ b/app/scenes/navigationSceneConnect.js @@ -18,12 +18,12 @@ const defaults = { renderBackButton: (props, emitter, theme) => { return ( { + 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 {displayName}; + } + + return null; + } + + buildDisplayBlock = (property) => { + const {theme, user} = this.props; + const style = createStyleSheet(theme); + + if (user.hasOwnProperty(property) && user[property].length > 0) { + return ( + + {property.toUpperCase()} + {user[property]} + + ); + } + + return null; + } + + sendMessage = () => { + this.props.actions.handleSendMessage(this.props.user.id); + } + + render() { + const {theme, user} = this.props; + const style = createStyleSheet(theme); + + return ( + + + + + {this.getDisplayName()} + {`@${user.username}`} + + + {this.buildDisplayBlock('username')} + {this.buildDisplayBlock('email')} + {this.buildDisplayBlock('position')} + + {this.props.currentUserId !== this.props.user.id && + + } + + + ); + } +} diff --git a/app/scenes/user_profile/user_profile_container.js b/app/scenes/user_profile/user_profile_container.js new file mode 100644 index 000000000..52b0a799c --- /dev/null +++ b/app/scenes/user_profile/user_profile_container.js @@ -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); diff --git a/app/scenes/user_profile/user_profile_row.js b/app/scenes/user_profile/user_profile_row.js new file mode 100644 index 000000000..af12b4b2d --- /dev/null +++ b/app/scenes/user_profile/user_profile_row.js @@ -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 ( + + {children} + + ); +} + +function userProfileRow(props) { + const {action, defaultMessage, detail, icon, textId, togglable, theme, shouldRender = true} = props; + + if (!shouldRender) { + return null; + } + + const style = createStyleSheet(theme); + + const RowComponent = ( + + + + + {detail} + {togglable ? + : + + } + + + ); + + 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; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2f6d8cbf6..e4ce87234 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/test/app/reducers/navigation.test.js b/test/app/reducers/navigation.test.js index 31fa8c5c3..e84d445eb 100644 --- a/test/app/reducers/navigation.test.js +++ b/test/app/reducers/navigation.test.js @@ -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();