Commit da173275 authored by Diego Mello's avatar Diego Mello Committed by Guilherme Gazzo

[NEW] User Profile (#323)

* Drawer layout

* Drawer changes

* Profile

* Profile avatar

* Set language

* Tests

* Custom fields

* Readme updated

* fix invalid user muted value

* Fix for "Cannot add a child that doesn't have a YogaNode to a parent without a measure function! (Trying to add a 'RCTVirtualText' to a 'RCTView')"
parent 802eff26
......@@ -12,6 +12,12 @@
**Supported Server Versions:** 0.58.0+ (We are working to support earlier versions)
# Download
[![Rocket.Chat.ReactNative on Google Play](https://play.google.com/intl/en_us/badges/images/badge_new.png)](https://play.google.com/store/apps/details?id=chat.rocket.reactnative)
Note: If you want to try iOS version, send us an email to testflight@rocket.chat and we'll add you to TestFlight users.
# Installing dependencies
Follow the [React Native Getting Started Guide](https://facebook.github.io/react-native/docs/getting-started.html) for detailed instructions on setting up your local machine for development.
......
......@@ -585,7 +585,7 @@ exports[`render unread +999 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/name",
"uri": "/avatar/name?random=0",
}
}
style={
......@@ -835,7 +835,7 @@ exports[`render unread 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/name",
"uri": "/avatar/name?random=0",
}
}
style={
......@@ -1085,7 +1085,7 @@ exports[`renders correctly 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/name",
"uri": "/avatar/name?random=0",
}
}
style={
......
......@@ -62,7 +62,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/test",
"uri": "/avatar/test?random=0",
}
}
style={
......@@ -136,7 +136,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/aa",
"uri": "/avatar/aa?random=0",
}
}
style={
......@@ -210,7 +210,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/bb",
"uri": "/avatar/bb?random=0",
}
}
style={
......@@ -284,7 +284,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/test",
"uri": "/avatar/test?random=0",
}
}
style={
......@@ -393,7 +393,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/rocket.cat",
"uri": "/avatar/rocket.cat?random=0",
}
}
style={
......@@ -615,7 +615,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/rocket.cat",
"uri": "/avatar/rocket.cat?random=0",
}
}
style={
......@@ -841,7 +841,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/rocket.cat",
"uri": "/avatar/rocket.cat?random=0",
}
}
style={
......@@ -1086,7 +1086,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0",
}
}
style={
......@@ -1335,7 +1335,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0",
}
}
style={
......@@ -1580,7 +1580,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0",
}
}
style={
......@@ -1825,7 +1825,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0",
}
}
style={
......@@ -2070,7 +2070,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries",
"uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0",
}
}
style={
......@@ -2315,7 +2315,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/W",
"uri": "/avatar/W?random=0",
}
}
style={
......@@ -2537,7 +2537,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/WW",
"uri": "/avatar/WW?random=0",
}
}
style={
......@@ -2759,7 +2759,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={
Object {
"priority": "high",
"uri": "/avatar/",
"uri": "/avatar/?random=0",
}
}
style={
......
......@@ -120,8 +120,10 @@ export function forgotPasswordFailure(err) {
export function setUser(action) {
return {
type: types.USER.SET,
...action
// do not change this params order
// since we use spread operator, sometimes `type` is overriden
...action,
type: types.USER.SET
};
}
......
......@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { StyleSheet, Text, View, ViewPropTypes } from 'react-native';
import FastImage from 'react-native-fast-image';
import avatarInitialsAndColor from '../utils/avatarInitialsAndColor';
import database from '../lib/realm';
const styles = StyleSheet.create({
iconContainer: {
......@@ -26,17 +27,78 @@ export default class Avatar extends React.PureComponent {
static propTypes = {
style: ViewPropTypes.style,
baseUrl: PropTypes.string,
text: PropTypes.string.isRequired,
text: PropTypes.string,
avatar: PropTypes.string,
size: PropTypes.number,
borderRadius: PropTypes.number,
type: PropTypes.string,
children: PropTypes.object
children: PropTypes.object,
forceInitials: PropTypes.bool
};
state = { showInitials: true };
static defaultProps = {
text: '',
size: 25,
type: 'd',
borderRadius: 2,
forceInitials: false
};
state = { showInitials: true, user: {} };
componentDidMount() {
const { text, type } = this.props;
if (type === 'd') {
this.users = this.userQuery(text);
this.users.addListener(this.update);
this.update();
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.text !== this.props.text && nextProps.type === 'd') {
if (this.users) {
this.users.removeAllListeners();
}
this.users = this.userQuery(nextProps.text);
this.users.addListener(this.update);
this.update();
}
}
componentWillUnmount() {
if (this.users) {
this.users.removeAllListeners();
}
}
get avatarVersion() {
return (this.state.user && this.state.user.avatarVersion) || 0;
}
/** FIXME: Workaround
* While we don't have containers/components structure, this is breaking tests.
* In that case, avatar would be a component, it would receive an `avatarVersion` param
* and we would have a avatar container in charge of making queries.
* Also, it would make possible to write unit tests like these.
*/
userQuery = (username) => {
if (database && database.databases && database.databases.activeDB) {
return database.objects('users').filtered('username = $0', username);
}
return {
addListener: () => {},
removeAllListeners: () => {}
};
}
update = () => {
if (this.users.length) {
this.setState({ user: this.users[0] });
}
}
render() {
const {
text = '', size = 25, baseUrl, borderRadius = 2, style, avatar, type = 'd'
text, size, baseUrl, borderRadius, style, avatar, type, forceInitials
} = this.props;
const { initials, color } = avatarInitialsAndColor(`${ text }`);
......@@ -60,9 +122,9 @@ export default class Avatar extends React.PureComponent {
let image;
if (type === 'd') {
const uri = avatar || `${ baseUrl }/avatar/${ text }`;
image = uri && (
if (type === 'd' && !forceInitials) {
const uri = avatar || `${ baseUrl }/avatar/${ text }?random=${ this.avatarVersion }`;
image = uri ? (
<FastImage
style={[styles.avatar, avatarStyle]}
source={{
......@@ -70,18 +132,19 @@ export default class Avatar extends React.PureComponent {
priority: FastImage.priority.high
}}
/>
);
) : null;
}
return (
<View style={[styles.iconContainer, iconContainerStyle, style]}>
{this.state.showInitials &&
{this.state.showInitials ?
<Text
style={[styles.avatarInitials, avatarInitialsStyle]}
allowFontScaling={false}
>
{initials}
</Text>
: null
}
{image}
{this.props.children}
......
......@@ -172,7 +172,7 @@ export default class MessageBox extends React.PureComponent {
maxWidth: 1960,
quality: 0.8
};
ImagePicker.showImagePicker(options, (response) => {
ImagePicker.showImagePicker(options, async(response) => {
if (response.didCancel) {
console.warn('User cancelled image picker');
} else if (response.error) {
......@@ -185,7 +185,11 @@ export default class MessageBox extends React.PureComponent {
// description: '',
store: 'Uploads'
};
RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data);
try {
await RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data);
} catch (e) {
log('addFile', e);
}
}
});
}
......@@ -459,6 +463,7 @@ export default class MessageBox extends React.PureComponent {
style={{ margin: 8 }}
text={item.username || item.name}
size={30}
type={item.username ? 'd' : 'c'}
/>,
<Text key='mention-item-name'>{ item.username || item.name }</Text>
]
......@@ -477,7 +482,7 @@ export default class MessageBox extends React.PureComponent {
style={styles.mentionList}
data={mentions}
renderItem={({ item }) => this.renderMentionItem(item)}
keyExtractor={item => item._id || item}
keyExtractor={item => item._id || item.username || item}
keyboardShouldPersistTaps='always'
/>
</View>
......
......@@ -95,26 +95,20 @@ export default class Sidebar extends Component {
super(props);
this.state = {
servers: [],
status: [{
id: 'online',
name: I18n.t('Online')
}, {
id: 'busy',
name: I18n.t('Busy')
}, {
id: 'away',
name: I18n.t('Away')
}, {
id: 'offline',
name: I18n.t('Invisible')
}],
showServers: false
};
}
componentDidMount() {
database.databases.serversDB.addListener('change', this.updateState);
this.setState(this.getState());
this.setStatus();
database.databases.serversDB.addListener('change', this.updateState);
}
componentWillReceiveProps(nextProps) {
if (nextProps.user && this.props.user && this.props.user.language !== nextProps.user.language) {
this.setStatus();
}
}
componentWillUnmount() {
......@@ -126,6 +120,26 @@ export default class Sidebar extends Component {
this.closeDrawer();
}
setStatus = () => {
setTimeout(() => {
this.setState({
status: [{
id: 'online',
name: I18n.t('Online')
}, {
id: 'busy',
name: I18n.t('Busy')
}, {
id: 'away',
name: I18n.t('Away')
}, {
id: 'offline',
name: I18n.t('Invisible')
}]
});
});
}
getState = () => ({
servers: database.databases.serversDB.objects('servers')
})
......@@ -153,6 +167,8 @@ export default class Sidebar extends Component {
const { navigate } = this.props.navigation;
if (!this.isRouteFocused(route)) {
navigate(route);
} else {
this.closeDrawer();
}
}
......@@ -211,6 +227,7 @@ export default class Sidebar extends Component {
this.toggleServers();
if (this.props.server !== item.id) {
this.props.selectServer(item.id);
this.props.navigation.navigate('RoomsList');
}
},
testID: `sidebar-${ item.id }`
......@@ -324,8 +341,8 @@ export default class Sidebar extends Component {
{this.renderSeparator('separator-header')}
{!this.state.showServers && this.renderNavigation()}
{this.state.showServers && this.renderServers()}
{!this.state.showServers ? this.renderNavigation() : null}
{this.state.showServers ? this.renderServers() : null}
</SafeAreaView>
</ScrollView>
);
......
......@@ -105,7 +105,7 @@ export default class RCTextInput extends React.PureComponent {
const { showPassword } = this.state;
return (
<View style={[styles.inputContainer, containerStyle]}>
{label && <Text contentDescription={null} accessibilityLabel={null} style={[styles.label, error.error && styles.labelError]}>{label}</Text> }
{label ? <Text contentDescription={null} accessibilityLabel={null} style={[styles.label, error.error && styles.labelError]}>{label}</Text> : null }
<View style={styles.wrap}>
<TextInput
style={[
......@@ -126,10 +126,10 @@ export default class RCTextInput extends React.PureComponent {
contentDescription={placeholder}
{...inputProps}
/>
{iconLeft && this.iconLeft(iconLeft)}
{secureTextEntry && this.iconPassword(showPassword ? 'eye-off' : 'eye')}
{iconLeft ? this.iconLeft(iconLeft) : null}
{secureTextEntry ? this.iconPassword(showPassword ? 'eye-off' : 'eye') : null}
</View>
{error.error && <Text style={sharedStyles.error}>{error.reason}</Text>}
{error.error ? <Text style={sharedStyles.error}>{error.reason}</Text> : null}
</View>
);
}
......
......@@ -78,7 +78,6 @@ const Reply = ({ attachment, timeFormat }) => {
<Avatar
text={attachment.author_name}
size={16}
avatar={attachment.author_icon}
/>
);
};
......@@ -136,7 +135,11 @@ const Reply = ({ attachment, timeFormat }) => {
{renderTitle()}
{renderText()}
{renderFields()}
{attachment.attachments && attachment.attachments.map(attach => <Reply key={attach.text} attachment={attach} timeFormat={timeFormat} />)}
{attachment.attachments ?
attachment.attachments
.map(attach => <Reply key={attach.text} attachment={attach} timeFormat={timeFormat} />)
: null
}
</View>
</TouchableOpacity>
);
......
......@@ -358,13 +358,14 @@ export default class Message extends React.Component {
{this.renderBroadcastReply()}
</View>
</View>
{this.state.reactionsModal &&
{this.state.reactionsModal ?
<ReactionsModal
isVisible={this.state.reactionsModal}
onClose={this.onClose}
reactions={item.reactions}
user={this.props.user}
/>
: null
}
</View>
</Touch>
......
import React from 'react';
import { Platform } from 'react-native';
import { Platform, TouchableOpacity } from 'react-native';
import { createStackNavigator, createDrawerNavigator } from 'react-navigation';
import Icon from 'react-native-vector-icons/MaterialIcons';
......@@ -22,6 +22,7 @@ import RoomInfoEditView from '../../views/RoomInfoEditView';
import ProfileView from '../../views/ProfileView';
import SettingsView from '../../views/SettingsView';
import I18n from '../../i18n';
import sharedStyles from '../../views/Styles';
const headerTintColor = '#292E35';
......@@ -132,12 +133,24 @@ const AuthRoutes = createStackNavigator(
}
);
const MenuButton = ({ navigation, testID }) => (
<TouchableOpacity
style={sharedStyles.headerButton}
onPress={navigation.toggleDrawer}
accessibilityLabel={I18n.t('Toggle_Drawer')}
accessibilityTraits='button'
testID={testID}
>
<Icon name='menu' size={30} color='#292E35' />
</TouchableOpacity>
);
const Routes = createDrawerNavigator(
{
Chats: {
screen: AuthRoutes,
navigationOptions: {
drawerLabel: 'Chats',
drawerLabel: I18n.t('Chats'),
drawerIcon: () => <Icon name='chat-bubble' size={20} />
}
},
......@@ -146,9 +159,9 @@ const Routes = createDrawerNavigator(
ProfileView: {
screen: ProfileView,
navigationOptions: ({ navigation }) => ({
title: 'Profile',
title: I18n.t('Profile'),
headerTintColor: '#292E35',
headerLeft: <Icon name='menu' size={30} color='#292E35' onPress={() => navigation.toggleDrawer()} /> // TODO: refactor
headerLeft: <MenuButton navigation={navigation} testID='profile-view-sidebar' />
})
}
})
......@@ -158,9 +171,9 @@ const Routes = createDrawerNavigator(
SettingsView: {
screen: SettingsView,
navigationOptions: ({ navigation }) => ({
title: 'Settings',
title: I18n.t('Settings'),
headerTintColor: '#292E35',
headerLeft: <Icon name='menu' size={30} color='#292E35' onPress={() => navigation.toggleDrawer()} /> // TODO: refactor
headerLeft: <MenuButton navigation={navigation} testID='settings-view-sidebar' />
})
}
})
......@@ -168,9 +181,7 @@ const Routes = createDrawerNavigator(
},
{
contentComponent: Sidebar,
navigationOptions: {
drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked'
},
drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked',
initialRouteName: 'Chats',
backBehavior: 'initialRoute'
}
......
export default {
'1_online_member': '1 online member',
'1_person_reacted': '1 person reacted',
'error-action-not-allowed': '{{action}} is not allowed',
'error-application-not-found': 'Application not found',
'error-archived-duplicate-name': 'There\'s an archived channel with name {{room_name}}',
'error-avatar-invalid-url': 'Invalid avatar URL: {{url}}',
'error-avatar-url-handling': 'Error while handling avatar setting from a URL ({{url}}) for {{username}}',
'error-cant-invite-for-direct-room': 'Can\'t invite user to direct rooms',
'error-could-not-change-email': 'Could not change email',
'error-could-not-change-name': 'Could not change name',
'error-could-not-change-username': 'Could not change username',
'error-delete-protected-role': 'Cannot delete a protected role',
'error-department-not-found': 'Department not found',
'error-direct-message-file-upload-not-allowed': 'File sharing not allowed in direct messages',
'error-duplicate-channel-name': 'A channel with name {{channel_name}} exists',
'error-email-domain-blacklisted': 'The email domain is blacklisted',
'error-email-send-failed': 'Error trying to send email: {{message}}',
'error-field-unavailable': '{{field}} is already in use :(',
'error-file-too-large': 'File is too large',
'error-importer-not-defined': 'The importer was not defined correctly, it is missing the Import class.',
'error-input-is-not-a-valid-field': '{{input}} is not a valid {{field}}',
'error-invalid-actionlink': 'Invalid action link',
'error-invalid-arguments': 'Invalid arguments',
'error-invalid-asset': 'Invalid asset',