[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')"
This commit is contained in:
Diego Mello 2018-06-12 22:33:00 -03:00 committed by Guilherme Gazzo
parent 802eff267c
commit da173275ce
49 changed files with 1279 additions and 331 deletions

View File

@ -12,6 +12,12 @@
**Supported Server Versions:** 0.58.0+ (We are working to support earlier versions) **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 # 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. 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.

View File

@ -585,7 +585,7 @@ exports[`render unread +999 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/name", "uri": "/avatar/name?random=0",
} }
} }
style={ style={
@ -835,7 +835,7 @@ exports[`render unread 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/name", "uri": "/avatar/name?random=0",
} }
} }
style={ style={
@ -1085,7 +1085,7 @@ exports[`renders correctly 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/name", "uri": "/avatar/name?random=0",
} }
} }
style={ style={

View File

@ -62,7 +62,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/test", "uri": "/avatar/test?random=0",
} }
} }
style={ style={
@ -136,7 +136,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/aa", "uri": "/avatar/aa?random=0",
} }
} }
style={ style={
@ -210,7 +210,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/bb", "uri": "/avatar/bb?random=0",
} }
} }
style={ style={
@ -284,7 +284,7 @@ exports[`Storyshots Avatar avatar 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/test", "uri": "/avatar/test?random=0",
} }
} }
style={ style={
@ -393,7 +393,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/rocket.cat", "uri": "/avatar/rocket.cat?random=0",
} }
} }
style={ style={
@ -615,7 +615,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/rocket.cat", "uri": "/avatar/rocket.cat?random=0",
} }
} }
style={ style={
@ -841,7 +841,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/rocket.cat", "uri": "/avatar/rocket.cat?random=0",
} }
} }
style={ style={
@ -1086,7 +1086,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "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={ style={
@ -1335,7 +1335,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "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={ style={
@ -1580,7 +1580,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "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={ style={
@ -1825,7 +1825,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "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={ style={
@ -2070,7 +2070,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "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={ style={
@ -2315,7 +2315,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/W", "uri": "/avatar/W?random=0",
} }
} }
style={ style={
@ -2537,7 +2537,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/WW", "uri": "/avatar/WW?random=0",
} }
} }
style={ style={
@ -2759,7 +2759,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = `
source={ source={
Object { Object {
"priority": "high", "priority": "high",
"uri": "/avatar/", "uri": "/avatar/?random=0",
} }
} }
style={ style={

View File

@ -120,8 +120,10 @@ export function forgotPasswordFailure(err) {
export function setUser(action) { export function setUser(action) {
return { return {
type: types.USER.SET, // do not change this params order
...action // since we use spread operator, sometimes `type` is overriden
...action,
type: types.USER.SET
}; };
} }

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { StyleSheet, Text, View, ViewPropTypes } from 'react-native'; import { StyleSheet, Text, View, ViewPropTypes } from 'react-native';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import avatarInitialsAndColor from '../utils/avatarInitialsAndColor'; import avatarInitialsAndColor from '../utils/avatarInitialsAndColor';
import database from '../lib/realm';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
iconContainer: { iconContainer: {
@ -26,17 +27,78 @@ export default class Avatar extends React.PureComponent {
static propTypes = { static propTypes = {
style: ViewPropTypes.style, style: ViewPropTypes.style,
baseUrl: PropTypes.string, baseUrl: PropTypes.string,
text: PropTypes.string.isRequired, text: PropTypes.string,
avatar: PropTypes.string, avatar: PropTypes.string,
size: PropTypes.number, size: PropTypes.number,
borderRadius: PropTypes.number, borderRadius: PropTypes.number,
type: PropTypes.string, 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() { render() {
const { const {
text = '', size = 25, baseUrl, borderRadius = 2, style, avatar, type = 'd' text, size, baseUrl, borderRadius, style, avatar, type, forceInitials
} = this.props; } = this.props;
const { initials, color } = avatarInitialsAndColor(`${ text }`); const { initials, color } = avatarInitialsAndColor(`${ text }`);
@ -60,9 +122,9 @@ export default class Avatar extends React.PureComponent {
let image; let image;
if (type === 'd') { if (type === 'd' && !forceInitials) {
const uri = avatar || `${ baseUrl }/avatar/${ text }`; const uri = avatar || `${ baseUrl }/avatar/${ text }?random=${ this.avatarVersion }`;
image = uri && ( image = uri ? (
<FastImage <FastImage
style={[styles.avatar, avatarStyle]} style={[styles.avatar, avatarStyle]}
source={{ source={{
@ -70,18 +132,19 @@ export default class Avatar extends React.PureComponent {
priority: FastImage.priority.high priority: FastImage.priority.high
}} }}
/> />
); ) : null;
} }
return ( return (
<View style={[styles.iconContainer, iconContainerStyle, style]}> <View style={[styles.iconContainer, iconContainerStyle, style]}>
{this.state.showInitials && {this.state.showInitials ?
<Text <Text
style={[styles.avatarInitials, avatarInitialsStyle]} style={[styles.avatarInitials, avatarInitialsStyle]}
allowFontScaling={false} allowFontScaling={false}
> >
{initials} {initials}
</Text> </Text>
: null
} }
{image} {image}
{this.props.children} {this.props.children}

View File

@ -172,7 +172,7 @@ export default class MessageBox extends React.PureComponent {
maxWidth: 1960, maxWidth: 1960,
quality: 0.8 quality: 0.8
}; };
ImagePicker.showImagePicker(options, (response) => { ImagePicker.showImagePicker(options, async(response) => {
if (response.didCancel) { if (response.didCancel) {
console.warn('User cancelled image picker'); console.warn('User cancelled image picker');
} else if (response.error) { } else if (response.error) {
@ -185,7 +185,11 @@ export default class MessageBox extends React.PureComponent {
// description: '', // description: '',
store: 'Uploads' 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 }} style={{ margin: 8 }}
text={item.username || item.name} text={item.username || item.name}
size={30} size={30}
type={item.username ? 'd' : 'c'}
/>, />,
<Text key='mention-item-name'>{ item.username || item.name }</Text> <Text key='mention-item-name'>{ item.username || item.name }</Text>
] ]
@ -477,7 +482,7 @@ export default class MessageBox extends React.PureComponent {
style={styles.mentionList} style={styles.mentionList}
data={mentions} data={mentions}
renderItem={({ item }) => this.renderMentionItem(item)} renderItem={({ item }) => this.renderMentionItem(item)}
keyExtractor={item => item._id || item} keyExtractor={item => item._id || item.username || item}
keyboardShouldPersistTaps='always' keyboardShouldPersistTaps='always'
/> />
</View> </View>

View File

@ -95,6 +95,34 @@ export default class Sidebar extends Component {
super(props); super(props);
this.state = { this.state = {
servers: [], servers: [],
showServers: false
};
}
componentDidMount() {
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() {
database.databases.serversDB.removeListener('change', this.updateState);
}
onPressItem = (item) => {
this.props.selectServer(item.id);
this.closeDrawer();
}
setStatus = () => {
setTimeout(() => {
this.setState({
status: [{ status: [{
id: 'online', id: 'online',
name: I18n.t('Online') name: I18n.t('Online')
@ -107,23 +135,9 @@ export default class Sidebar extends Component {
}, { }, {
id: 'offline', id: 'offline',
name: I18n.t('Invisible') name: I18n.t('Invisible')
}], }]
showServers: false });
}; });
}
componentDidMount() {
database.databases.serversDB.addListener('change', this.updateState);
this.setState(this.getState());
}
componentWillUnmount() {
database.databases.serversDB.removeListener('change', this.updateState);
}
onPressItem = (item) => {
this.props.selectServer(item.id);
this.closeDrawer();
} }
getState = () => ({ getState = () => ({
@ -153,6 +167,8 @@ export default class Sidebar extends Component {
const { navigate } = this.props.navigation; const { navigate } = this.props.navigation;
if (!this.isRouteFocused(route)) { if (!this.isRouteFocused(route)) {
navigate(route); navigate(route);
} else {
this.closeDrawer();
} }
} }
@ -211,6 +227,7 @@ export default class Sidebar extends Component {
this.toggleServers(); this.toggleServers();
if (this.props.server !== item.id) { if (this.props.server !== item.id) {
this.props.selectServer(item.id); this.props.selectServer(item.id);
this.props.navigation.navigate('RoomsList');
} }
}, },
testID: `sidebar-${ item.id }` testID: `sidebar-${ item.id }`
@ -324,8 +341,8 @@ export default class Sidebar extends Component {
{this.renderSeparator('separator-header')} {this.renderSeparator('separator-header')}
{!this.state.showServers && this.renderNavigation()} {!this.state.showServers ? this.renderNavigation() : null}
{this.state.showServers && this.renderServers()} {this.state.showServers ? this.renderServers() : null}
</SafeAreaView> </SafeAreaView>
</ScrollView> </ScrollView>
); );

View File

@ -105,7 +105,7 @@ export default class RCTextInput extends React.PureComponent {
const { showPassword } = this.state; const { showPassword } = this.state;
return ( return (
<View style={[styles.inputContainer, containerStyle]}> <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}> <View style={styles.wrap}>
<TextInput <TextInput
style={[ style={[
@ -126,10 +126,10 @@ export default class RCTextInput extends React.PureComponent {
contentDescription={placeholder} contentDescription={placeholder}
{...inputProps} {...inputProps}
/> />
{iconLeft && this.iconLeft(iconLeft)} {iconLeft ? this.iconLeft(iconLeft) : null}
{secureTextEntry && this.iconPassword(showPassword ? 'eye-off' : 'eye')} {secureTextEntry ? this.iconPassword(showPassword ? 'eye-off' : 'eye') : null}
</View> </View>
{error.error && <Text style={sharedStyles.error}>{error.reason}</Text>} {error.error ? <Text style={sharedStyles.error}>{error.reason}</Text> : null}
</View> </View>
); );
} }

View File

@ -78,7 +78,6 @@ const Reply = ({ attachment, timeFormat }) => {
<Avatar <Avatar
text={attachment.author_name} text={attachment.author_name}
size={16} size={16}
avatar={attachment.author_icon}
/> />
); );
}; };
@ -136,7 +135,11 @@ const Reply = ({ attachment, timeFormat }) => {
{renderTitle()} {renderTitle()}
{renderText()} {renderText()}
{renderFields()} {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> </View>
</TouchableOpacity> </TouchableOpacity>
); );

View File

@ -358,13 +358,14 @@ export default class Message extends React.Component {
{this.renderBroadcastReply()} {this.renderBroadcastReply()}
</View> </View>
</View> </View>
{this.state.reactionsModal && {this.state.reactionsModal ?
<ReactionsModal <ReactionsModal
isVisible={this.state.reactionsModal} isVisible={this.state.reactionsModal}
onClose={this.onClose} onClose={this.onClose}
reactions={item.reactions} reactions={item.reactions}
user={this.props.user} user={this.props.user}
/> />
: null
} }
</View> </View>
</Touch> </Touch>

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Platform } from 'react-native'; import { Platform, TouchableOpacity } from 'react-native';
import { createStackNavigator, createDrawerNavigator } from 'react-navigation'; import { createStackNavigator, createDrawerNavigator } from 'react-navigation';
import Icon from 'react-native-vector-icons/MaterialIcons'; import Icon from 'react-native-vector-icons/MaterialIcons';
@ -22,6 +22,7 @@ import RoomInfoEditView from '../../views/RoomInfoEditView';
import ProfileView from '../../views/ProfileView'; import ProfileView from '../../views/ProfileView';
import SettingsView from '../../views/SettingsView'; import SettingsView from '../../views/SettingsView';
import I18n from '../../i18n'; import I18n from '../../i18n';
import sharedStyles from '../../views/Styles';
const headerTintColor = '#292E35'; 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( const Routes = createDrawerNavigator(
{ {
Chats: { Chats: {
screen: AuthRoutes, screen: AuthRoutes,
navigationOptions: { navigationOptions: {
drawerLabel: 'Chats', drawerLabel: I18n.t('Chats'),
drawerIcon: () => <Icon name='chat-bubble' size={20} /> drawerIcon: () => <Icon name='chat-bubble' size={20} />
} }
}, },
@ -146,9 +159,9 @@ const Routes = createDrawerNavigator(
ProfileView: { ProfileView: {
screen: ProfileView, screen: ProfileView,
navigationOptions: ({ navigation }) => ({ navigationOptions: ({ navigation }) => ({
title: 'Profile', title: I18n.t('Profile'),
headerTintColor: '#292E35', 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: { SettingsView: {
screen: SettingsView, screen: SettingsView,
navigationOptions: ({ navigation }) => ({ navigationOptions: ({ navigation }) => ({
title: 'Settings', title: I18n.t('Settings'),
headerTintColor: '#292E35', 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, contentComponent: Sidebar,
navigationOptions: { drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked',
drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked'
},
initialRouteName: 'Chats', initialRouteName: 'Chats',
backBehavior: 'initialRoute' backBehavior: 'initialRoute'
} }

View File

@ -1,6 +1,80 @@
export default { export default {
'1_online_member': '1 online member', '1_online_member': '1 online member',
'1_person_reacted': '1 person reacted', '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',
'error-invalid-channel': 'Invalid channel.',
'error-invalid-channel-start-with-chars': 'Invalid channel. Start with @ or #',
'error-invalid-custom-field': 'Invalid custom field',
'error-invalid-custom-field-name': 'Invalid custom field name. Use only letters, numbers, hyphens and underscores.',
'error-invalid-date': 'Invalid date provided.',
'error-invalid-description': 'Invalid description',
'error-invalid-domain': 'Invalid domain',
'error-invalid-email': 'Invalid email {{emai}}',
'error-invalid-email-address': 'Invalid email address',
'error-invalid-file-height': 'Invalid file height',
'error-invalid-file-type': 'Invalid file type',
'error-invalid-file-width': 'Invalid file width',
'error-invalid-from-address': 'You informed an invalid FROM address.',
'error-invalid-integration': 'Invalid integration',
'error-invalid-message': 'Invalid message',
'error-invalid-method': 'Invalid method',
'error-invalid-name': 'Invalid name',
'error-invalid-password': 'Invalid password',
'error-invalid-redirectUri': 'Invalid redirectUri',
'error-invalid-role': 'Invalid role',
'error-invalid-room': 'Invalid room',
'error-invalid-room-name': '{{room_name}} is not a valid room name',
'error-invalid-room-type': '{{type}} is not a valid room type.',
'error-invalid-settings': 'Invalid settings provided',
'error-invalid-subscription': 'Invalid subscription',
'error-invalid-token': 'Invalid token',
'error-invalid-triggerWords': 'Invalid triggerWords',
'error-invalid-urls': 'Invalid URLs',
'error-invalid-user': 'Invalid user',
'error-invalid-username': 'Invalid username',
'error-invalid-webhook-response': 'The webhook URL responded with a status other than 200',
'error-message-deleting-blocked': 'Message deleting is blocked',
'error-message-editing-blocked': 'Message editing is blocked',
'error-message-size-exceeded': 'Message size exceeds Message_MaxAllowedSize',
'error-missing-unsubscribe-link': 'You must provide the [unsubscribe] link.',
'error-no-tokens-for-this-user': 'There are no tokens for this user',
'error-not-allowed': 'Not allowed',
'error-not-authorized': 'Not authorized',
'error-push-disabled': 'Push is disabled',
'error-remove-last-owner': 'This is the last owner. Please set a new owner before removing this one.',
'error-role-in-use': 'Cannot delete role because it\'s in use',
'error-role-name-required': 'Role name is required',
'error-the-field-is-required': 'The field {{field}} is required.',
'error-too-many-requests': 'Error, too many requests. Please slow down. You must wait {{seconds}} seconds before trying again.',
'error-user-is-not-activated': 'User is not activated',
'error-user-has-no-roles': 'User has no roles',
'error-user-limit-exceeded': 'The number of users you are trying to invite to #channel_name exceeds the limit set by the administrator',
'error-user-not-in-room': 'User is not in this room',
'error-user-registration-custom-field': 'error-user-registration-custom-field',
'error-user-registration-disabled': 'User registration is disabled',
'error-user-registration-secret': 'User registration is only allowed via Secret URL',
'error-you-are-last-owner': 'You are the last owner. Please set new owner before leaving the room.',
Actions: 'Actions', Actions: 'Actions',
Add_Reaction: 'Add Reaction', Add_Reaction: 'Add Reaction',
Add_Server: 'Add Server', Add_Server: 'Add Server',
@ -21,6 +95,8 @@ export default {
Are_you_sure_question_mark: 'Are you sure?', Are_you_sure_question_mark: 'Are you sure?',
Are_you_sure_you_want_to_leave_the_room: 'Are you sure you want to leave the room {{room}}?', Are_you_sure_you_want_to_leave_the_room: 'Are you sure you want to leave the room {{room}}?',
Authenticating: 'Authenticating', Authenticating: 'Authenticating',
Avatar_changed_successfully: 'Avatar changed successfully!',
Avatar_Url: 'Avatar URL',
Away: 'Away', Away: 'Away',
Block_user: 'Block user', Block_user: 'Block user',
Broadcast_channel_Description: 'Only authorized users can write new messages, but the other users will be able to reply', Broadcast_channel_Description: 'Only authorized users can write new messages, but the other users will be able to reply',
@ -30,6 +106,7 @@ export default {
Cancel_editing: 'Cancel editing', Cancel_editing: 'Cancel editing',
Cancel_recording: 'Cancel recording', Cancel_recording: 'Cancel recording',
Cancel: 'Cancel', Cancel: 'Cancel',
changing_avatar: 'changing avatar',
Channel_Name: 'Channel Name', Channel_Name: 'Channel Name',
Chats: 'Chats', Chats: 'Chats',
Close_emoji_selector: 'Close emoji selector', Close_emoji_selector: 'Close emoji selector',
@ -60,6 +137,7 @@ export default {
Everyone_can_access_this_channel: 'Everyone can access this channel', Everyone_can_access_this_channel: 'Everyone can access this channel',
Files: 'Files', Files: 'Files',
Finish_recording: 'Finish recording', Finish_recording: 'Finish recording',
For_your_security_you_must_enter_your_current_password_to_continue: 'For your security, you must enter your current password to continue',
Forgot_my_password: 'Forgot my password', Forgot_my_password: 'Forgot my password',
Forgot_password_If_this_email_is_registered: 'If this email is registered, we\'ll send instructions on how to reset your password. If you do not receive an email shortly, please come back and try again.', Forgot_password_If_this_email_is_registered: 'If this email is registered, we\'ll send instructions on how to reset your password. If you do not receive an email shortly, please come back and try again.',
Forgot_password: 'Forgot password', Forgot_password: 'Forgot password',
@ -71,6 +149,7 @@ export default {
is_not_a_valid_RocketChat_instance: 'is not a valid Rocket.Chat instance', is_not_a_valid_RocketChat_instance: 'is not a valid Rocket.Chat instance',
is_typing: 'is typing', is_typing: 'is typing',
Just_invited_people_can_access_this_channel: 'Just invited people can access this channel', Just_invited_people_can_access_this_channel: 'Just invited people can access this channel',
Language: 'Language',
last_message: 'last message', last_message: 'last message',
Leave_channel: 'Leave channel', Leave_channel: 'Leave channel',
leave: 'leave', leave: 'leave',
@ -95,6 +174,7 @@ export default {
Name: 'Name', Name: 'Name',
New_in_RocketChat_question_mark: 'New in Rocket.Chat?', New_in_RocketChat_question_mark: 'New in Rocket.Chat?',
New_Message: 'New Message', New_Message: 'New Message',
New_Password: 'New Password',
New_Server: 'New Server', New_Server: 'New Server',
No_files: 'No files', No_files: 'No files',
No_mentioned_messages: 'No mentioned messages', No_mentioned_messages: 'No mentioned messages',
@ -121,9 +201,12 @@ export default {
Pinned_Messages: 'Pinned Messages', Pinned_Messages: 'Pinned Messages',
pinned: 'pinned', pinned: 'pinned',
Pinned: 'Pinned', Pinned: 'Pinned',
Please_enter_your_password: 'Please enter your password',
Preferences_saved: 'Preferences saved!',
Privacy_Policy: ' Privacy Policy', Privacy_Policy: ' Privacy Policy',
Private_Channel: 'Private Channel', Private_Channel: 'Private Channel',
Private: 'Private', Private: 'Private',
Profile_saved_successfully: 'Profile saved successfully!',
Profile: 'Profile', Profile: 'Profile',
Public_Channel: 'Public Channel', Public_Channel: 'Public Channel',
Public: 'Public', Public: 'Public',
@ -151,8 +234,14 @@ export default {
Room_Members: 'Room Members', Room_Members: 'Room Members',
Room_name_changed: 'Room name changed to: {{name}} by {{userBy}}', Room_name_changed: 'Room name changed to: {{name}} by {{userBy}}',
SAVE: 'SAVE', SAVE: 'SAVE',
Save_Changes: 'Save Changes',
Save: 'Save',
saving_preferences: 'saving preferences',
saving_profile: 'saving profile',
saving_settings: 'saving settings',
Search_Messages: 'Search Messages', Search_Messages: 'Search Messages',
Search: 'Search', Search: 'Search',
Select_Avatar: 'Select Avatar',
Select_Users: 'Select Users', Select_Users: 'Select Users',
Send_audio_message: 'Send audio message', Send_audio_message: 'Send audio message',
Send_message: 'Send message', Send_message: 'Send message',
@ -177,10 +266,11 @@ export default {
tap_to_change_status: 'tap to change status', tap_to_change_status: 'tap to change status',
Tap_to_view_servers_list: 'Tap to view servers list', Tap_to_view_servers_list: 'Tap to view servers list',
Terms_of_Service: ' Terms of Service ', Terms_of_Service: ' Terms of Service ',
There_was_an_error_while_saving_settings: 'There was an error while saving settings!', There_was_an_error_while_action: 'There was an error while {{action}}!',
This_room_is_blocked: 'This room is blocked', This_room_is_blocked: 'This room is blocked',
This_room_is_read_only: 'This room is read only', This_room_is_read_only: 'This room is read only',
Timezone: 'Timezone', Timezone: 'Timezone',
Toggle_Drawer: 'Toggle_Drawer',
topic: 'topic', topic: 'topic',
Topic: 'Topic', Topic: 'Topic',
Type_the_channel_name_here: 'Type the channel name here', Type_the_channel_name_here: 'Type the channel name here',

View File

@ -144,9 +144,11 @@ export default class Socket extends EventEmitter {
try { try {
this.emit('login', params); this.emit('login', params);
const result = await this.call('login', params); const result = await this.call('login', params);
this._login = { resume: result.token, ...result }; // this._login = { resume: result.token, ...result };
this._login = { resume: result.token, ...result, ...params };
this._logged = true; this._logged = true;
this.emit('logged', result); // this.emit('logged', result);
this.emit('logged', this._login);
return result; return result;
} catch (err) { } catch (err) {
const error = { ...err }; const error = { ...err };

View File

@ -106,11 +106,11 @@ const subscriptionSchema = {
const usersSchema = { const usersSchema = {
name: 'users', name: 'users',
primaryKey: '_id', primaryKey: 'username',
properties: { properties: {
_id: 'string',
username: 'string', username: 'string',
name: { type: 'string', optional: true } name: { type: 'string', optional: true },
avatarVersion: { type: 'int', optional: true }
} }
}; };

View File

@ -91,10 +91,7 @@ const RocketChat = {
this.activeUsers = this.activeUsers || {}; this.activeUsers = this.activeUsers || {};
const { user } = reduxStore.getState().login; const { user } = reduxStore.getState().login;
if (user && user.id === ddpMessage.id) { if (ddpMessage.fields && user && user.id === ddpMessage.id) {
if (!ddpMessage.fields) {
reduxStore.dispatch(setUser({ status: 'offline' }));
}
reduxStore.dispatch(setUser(ddpMessage.fields)); reduxStore.dispatch(setUser(ddpMessage.fields));
} }
@ -107,9 +104,14 @@ const RocketChat = {
reduxStore.dispatch(setActiveUser(this.activeUsers)); reduxStore.dispatch(setActiveUser(this.activeUsers));
this._setUserTimer = null; this._setUserTimer = null;
return this.activeUsers = {}; return this.activeUsers = {};
}, 1000); }, 3000);
this.activeUsers[ddpMessage.id] = ddpMessage.fields; const activeUser = reduxStore.getState().activeUsers[ddpMessage.id];
if (!ddpMessage.fields) {
this.activeUsers[ddpMessage.id] = {};
} else {
this.activeUsers[ddpMessage.id] = { ...this.activeUsers[ddpMessage.id], ...activeUser, ...ddpMessage.fields };
}
}, },
async loginSuccess(user) { async loginSuccess(user) {
try { try {
@ -122,15 +124,11 @@ const RocketChat = {
// call /me only one time // call /me only one time
if (!user.username) { if (!user.username) {
const me = await this.me({ token: user.token, userId: user.id }); const me = await this.me({ token: user.token, userId: user.id });
// eslint-disable-next-line user = { ...user, ...me };
user.username = me.username;
} }
if (user.username) { if (user.username) {
const userInfo = await this.userInfo({ token: user.token, userId: user.id }); const userInfo = await this.userInfo({ token: user.token, userId: user.id });
user.username = userInfo.user.username; user = { ...user, ...userInfo.user };
if (userInfo.user.roles) {
user.roles = userInfo.user.roles;
}
} }
return reduxStore.dispatch(loginSuccess(user)); return reduxStore.dispatch(loginSuccess(user));
} catch (e) { } catch (e) {
@ -163,7 +161,10 @@ const RocketChat = {
this.getRooms().catch(e => log('logged getRooms', e)); this.getRooms().catch(e => log('logged getRooms', e));
this.loginSuccess(user); this.loginSuccess(user);
})); }));
this.ddp.once('logged', protectedFunction(({ id }) => { this.subscribeRooms(id); })); this.ddp.once('logged', protectedFunction(({ id }) => {
this.subscribeRooms(id);
this.ddp.subscribe('stream-notify-logged', 'updateAvatar', false);
}));
this.ddp.on('disconnected', protectedFunction(() => { this.ddp.on('disconnected', protectedFunction(() => {
reduxStore.dispatch(disconnect()); reduxStore.dispatch(disconnect());
@ -184,6 +185,24 @@ const RocketChat = {
return reduxStore.dispatch(someoneTyping({ _rid, username: ddpMessage.fields.args[0], typing: ddpMessage.fields.args[1] })); return reduxStore.dispatch(someoneTyping({ _rid, username: ddpMessage.fields.args[0], typing: ddpMessage.fields.args[1] }));
})); }));
this.ddp.on('stream-notify-logged', (ddpMessage) => {
// this entire logic needs a better solution
// we're using it only because our image cache lib doesn't support clear cache
if (ddpMessage.fields && ddpMessage.fields.eventName === 'updateAvatar') {
const { args } = ddpMessage.fields;
database.write(() => {
args.forEach((arg) => {
const user = database.objects('users').filtered('username = $0', arg.username);
if (!user.length) {
database.create('users', { username: arg.username, avatarVersion: 0 });
} else {
user[0].avatarVersion += 1;
}
});
});
}
});
// this.ddp.on('stream-notify-user', protectedFunction((ddpMessage) => { // this.ddp.on('stream-notify-user', protectedFunction((ddpMessage) => {
// console.warn('rc.stream-notify-user') // console.warn('rc.stream-notify-user')
// const [type, data] = ddpMessage.fields.args; // const [type, data] = ddpMessage.fields.args;
@ -804,6 +823,12 @@ const RocketChat = {
saveRoomSettings(rid, params) { saveRoomSettings(rid, params) {
return call('saveRoomSettings', rid, params); return call('saveRoomSettings', rid, params);
}, },
saveUserProfile(params, customFields) {
return call('saveUserProfile', params, customFields);
},
saveUserPreferences(params) {
return call('saveUserPreferences', params);
},
saveNotificationSettings(rid, param, value) { saveNotificationSettings(rid, param, value) {
return call('saveNotificationSettings', rid, param, value); return call('saveNotificationSettings', rid, param, value);
}, },
@ -836,6 +861,15 @@ const RocketChat = {
.some(item => mergedRoles.indexOf(item) !== -1); .some(item => mergedRoles.indexOf(item) !== -1);
return result; return result;
}, {}); }, {});
},
getAvatarSuggestion() {
return call('getAvatarSuggestion');
},
resetAvatar() {
return call('resetAvatar');
},
setAvatarFromService({ data, contentType = '', service = null }) {
return call('setAvatarFromService', data, contentType, service);
} }
}; };

View File

@ -20,8 +20,8 @@ const restore = function* restore() {
yield put(setServer(currentServer)); yield put(setServer(currentServer));
const login = yield call([AsyncStorage, 'getItem'], `${ RocketChat.TOKEN_KEY }-${ currentServer }`); const login = yield call([AsyncStorage, 'getItem'], `${ RocketChat.TOKEN_KEY }-${ currentServer }`);
if (login && login.user) { if (login) {
yield put(setUser(login.user)); yield put(setUser(JSON.parse(login)));
} }
} }

View File

@ -20,6 +20,7 @@ import {
import RocketChat from '../lib/rocketchat'; import RocketChat from '../lib/rocketchat';
import * as NavigationService from '../containers/routes/NavigationService'; import * as NavigationService from '../containers/routes/NavigationService';
import log from '../utils/log'; import log from '../utils/log';
import I18n from '../i18n';
const getUser = state => state.login; const getUser = state => state.login;
const getServer = state => state.server.server; const getServer = state => state.server.server;
@ -170,6 +171,15 @@ const watchLoginOpen = function* watchLoginOpen() {
} }
}; };
// eslint-disable-next-line require-yield
const handleSetUser = function* handleSetUser(params) {
const [server, user] = yield all([select(getServer), select(getUser)]);
if (params.language) {
I18n.locale = params.language;
}
yield AsyncStorage.setItem(`${ RocketChat.TOKEN_KEY }-${ server }`, JSON.stringify(user));
};
const root = function* root() { const root = function* root() {
// yield takeLatest(types.METEOR.SUCCESS, handleLoginWhenServerChanges); // yield takeLatest(types.METEOR.SUCCESS, handleLoginWhenServerChanges);
// yield takeLatest(types.LOGIN.REQUEST, handleLoginRequest); // yield takeLatest(types.LOGIN.REQUEST, handleLoginRequest);
@ -184,5 +194,6 @@ const root = function* root() {
yield takeLatest(types.LOGOUT, handleLogout); yield takeLatest(types.LOGOUT, handleLogout);
yield takeLatest(types.FORGOT_PASSWORD.REQUEST, handleForgotPasswordRequest); yield takeLatest(types.FORGOT_PASSWORD.REQUEST, handleForgotPasswordRequest);
yield takeLatest(types.LOGIN.OPEN, watchLoginOpen); yield takeLatest(types.LOGIN.OPEN, watchLoginOpen);
yield takeLatest(types.USER.SET, handleSetUser);
}; };
export default root; export default root;

View File

@ -78,7 +78,6 @@ export default class ForgotPasswordView extends LoggedView {
<ScrollView {...scrollPersistTaps} contentContainerStyle={styles.containerScrollView}> <ScrollView {...scrollPersistTaps} contentContainerStyle={styles.containerScrollView}>
<SafeAreaView testID='forgot-password-view'> <SafeAreaView testID='forgot-password-view'>
<View style={styles.loginView}> <View style={styles.loginView}>
<View style={styles.formContainer}>
<TextInput <TextInput
inputStyle={this.state.invalidEmail ? { borderColor: 'red' } : {}} inputStyle={this.state.invalidEmail ? { borderColor: 'red' } : {}}
label={I18n.t('Email')} label={I18n.t('Email')}
@ -99,8 +98,7 @@ export default class ForgotPasswordView extends LoggedView {
/> />
</View> </View>
{this.props.login.failure && <Text style={styles.error}>{this.props.login.error.reason}</Text>} {this.props.login.failure ? <Text style={styles.error}>{this.props.login.error.reason}</Text> : null}
</View>
<Loading visible={this.props.login.isFetching} /> <Loading visible={this.props.login.isFetching} />
</View> </View>
</SafeAreaView> </SafeAreaView>

View File

@ -209,61 +209,68 @@ export default class LoginSignupView extends LoggedView {
{I18n.t('Or_continue_using_social_accounts')} {I18n.t('Or_continue_using_social_accounts')}
</Text> </Text>
<View style={sharedStyles.loginOAuthButtons} key='services'> <View style={sharedStyles.loginOAuthButtons} key='services'>
{this.props.Accounts_OAuth_Facebook && this.props.services.facebook && {this.props.Accounts_OAuth_Facebook && this.props.services.facebook ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.facebookButton]} style={[sharedStyles.oauthButton, sharedStyles.facebookButton]}
onPress={this.onPressFacebook} onPress={this.onPressFacebook}
> >
<Icon name='facebook' size={20} color='#ffffff' /> <Icon name='facebook' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Github && this.props.services.github && {this.props.Accounts_OAuth_Github && this.props.services.github ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.githubButton]} style={[sharedStyles.oauthButton, sharedStyles.githubButton]}
onPress={this.onPressGithub} onPress={this.onPressGithub}
> >
<Icon name='github' size={20} color='#ffffff' /> <Icon name='github' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Gitlab && this.props.services.gitlab && {this.props.Accounts_OAuth_Gitlab && this.props.services.gitlab ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.gitlabButton]} style={[sharedStyles.oauthButton, sharedStyles.gitlabButton]}
onPress={this.onPressGitlab} onPress={this.onPressGitlab}
> >
<Icon name='gitlab' size={20} color='#ffffff' /> <Icon name='gitlab' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Google && this.props.services.google && {this.props.Accounts_OAuth_Google && this.props.services.google ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.googleButton]} style={[sharedStyles.oauthButton, sharedStyles.googleButton]}
onPress={this.onPressGoogle} onPress={this.onPressGoogle}
> >
<Icon name='google' size={20} color='#ffffff' /> <Icon name='google' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Linkedin && this.props.services.linkedin && {this.props.Accounts_OAuth_Linkedin && this.props.services.linkedin ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.linkedinButton]} style={[sharedStyles.oauthButton, sharedStyles.linkedinButton]}
onPress={this.onPressLinkedin} onPress={this.onPressLinkedin}
> >
<Icon name='linkedin' size={20} color='#ffffff' /> <Icon name='linkedin' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Meteor && this.props.services['meteor-developer'] && {this.props.Accounts_OAuth_Meteor && this.props.services['meteor-developer'] ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.meteorButton]} style={[sharedStyles.oauthButton, sharedStyles.meteorButton]}
onPress={this.onPressMeteor} onPress={this.onPressMeteor}
> >
<MaterialCommunityIcons name='meteor' size={25} color='#ffffff' /> <MaterialCommunityIcons name='meteor' size={25} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
{this.props.Accounts_OAuth_Twitter && this.props.services.twitter && {this.props.Accounts_OAuth_Twitter && this.props.services.twitter ?
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.oauthButton, sharedStyles.twitterButton]} style={[sharedStyles.oauthButton, sharedStyles.twitterButton]}
onPress={this.onPressTwitter} onPress={this.onPressTwitter}
> >
<Icon name='twitter' size={20} color='#ffffff' /> <Icon name='twitter' size={20} color='#ffffff' />
</TouchableOpacity> </TouchableOpacity>
: null
} }
</View> </View>
</View> </View>

View File

@ -135,7 +135,7 @@ export default class LoginView extends LoggedView {
</Text> </Text>
</View> </View>
{this.props.failure && <Text style={styles.error}>{this.props.reason}</Text>} {this.props.failure ? <Text style={styles.error}>{this.props.reason}</Text> : null}
<Loading visible={this.props.isFetching} /> <Loading visible={this.props.isFetching} />
</SafeAreaView> </SafeAreaView>
</ScrollView> </ScrollView>

View File

@ -109,8 +109,8 @@ export default class MentionedMessagesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={loading && <RCActivityIndicator />} ListHeaderComponent={loading ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
/> />
] ]
); );

View File

@ -133,8 +133,8 @@ export default class PinnedMessagesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={loading && <RCActivityIndicator />} ListHeaderComponent={loading ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
/>, />,
<ActionSheet <ActionSheet
key='pinned-messages-view-action-sheet' key='pinned-messages-view-action-sheet'

View File

@ -1,18 +1,436 @@
import React from 'react'; import React from 'react';
import { Text, View } from 'react-native'; import PropTypes from 'prop-types';
import { View, ScrollView, SafeAreaView, Keyboard } from 'react-native';
import { connect } from 'react-redux';
import Dialog from 'react-native-dialog';
import SHA256 from 'js-sha256';
import Icon from 'react-native-vector-icons/MaterialIcons';
import ImagePicker from 'react-native-image-picker';
import RNPickerSelect from 'react-native-picker-select';
import LoggedView from '../View'; import LoggedView from '../View';
import KeyboardView from '../../presentation/KeyboardView';
import sharedStyles from '../Styles';
import styles from './styles';
import scrollPersistTaps from '../../utils/scrollPersistTaps';
import { showErrorAlert, showToast } from '../../utils/info';
import RocketChat from '../../lib/rocketchat';
import RCTextInput from '../../containers/TextInput';
import Loading from '../../containers/Loading';
import log from '../../utils/log';
import I18n from '../../i18n';
import Button from '../../containers/Button';
import Avatar from '../../containers/Avatar';
import Touch from '../../utils/touch';
@connect(state => ({
user: state.login.user,
Accounts_CustomFields: state.settings.Accounts_CustomFields
}))
export default class ProfileView extends LoggedView { export default class ProfileView extends LoggedView {
static propTypes = {
navigation: PropTypes.object,
user: PropTypes.object,
Accounts_CustomFields: PropTypes.string
};
constructor(props) { constructor(props) {
super('ProfileView', props); super('ProfileView', props);
this.state = {
showPasswordAlert: false,
saving: false,
name: null,
username: null,
email: null,
newPassword: null,
typedPassword: null,
avatarUrl: null,
avatar: {},
avatarSuggestions: {},
customFields: {}
};
}
async componentDidMount() {
this.init();
try {
const result = await RocketChat.getAvatarSuggestion();
this.setState({ avatarSuggestions: result });
} catch (e) {
log('getAvatarSuggestion', e);
}
}
componentWillReceiveProps(nextProps) {
if (this.props.user !== nextProps.user) {
this.init(nextProps.user);
}
}
init = (user) => {
const {
name, username, emails, customFields
} = user || this.props.user;
this.setState({
name,
username,
email: emails ? emails[0].address : null,
newPassword: null,
typedPassword: null,
avatarUrl: null,
avatar: {},
customFields: customFields || {}
});
}
formIsChanged = () => {
const {
name, username, email, newPassword, avatar, customFields
} = this.state;
const { user } = this.props;
let customFieldsChanged = false;
const customFieldsKeys = Object.keys(customFields);
if (customFieldsKeys.length) {
customFieldsKeys.forEach((key) => {
if (user.customFields[key] !== customFields[key]) {
customFieldsChanged = true;
}
});
}
return !(user.name === name &&
user.username === username &&
!newPassword &&
(user.emails && user.emails[0].address === email) &&
!avatar.data &&
!customFieldsChanged
);
}
closePasswordAlert = () => {
this.setState({ showPasswordAlert: false });
}
handleError = (e, func, action) => {
if (e && e.error && e.error !== 500) {
if (e.details && e.details.timeToReset) {
return showErrorAlert(I18n.t('error-too-many-requests', {
seconds: parseInt(e.details.timeToReset / 1000, 10)
}));
}
return showErrorAlert(I18n.t(e.error, e.details));
}
showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) }));
log(func, e);
}
submit = async() => {
Keyboard.dismiss();
if (!this.formIsChanged()) {
return;
}
this.setState({ saving: true, showPasswordAlert: false });
const {
name, username, email, newPassword, typedPassword, avatar, customFields
} = this.state;
const { user } = this.props;
const params = {};
// Name
if (user.name !== name) {
params.realname = name;
}
// Username
if (user.username !== username) {
params.username = username;
}
// Email
if (user.emails && user.emails[0].address !== email) {
params.email = email;
}
// newPassword
if (newPassword) {
params.newPassword = newPassword;
}
// typedPassword
if (typedPassword) {
params.typedPassword = SHA256(typedPassword);
}
const requirePassword = !!params.email || newPassword;
if (requirePassword && !params.typedPassword) {
return this.setState({ showPasswordAlert: true, saving: false });
}
try {
if (avatar.url) {
try {
await RocketChat.setAvatarFromService(avatar);
} catch (e) {
this.setState({ saving: false, typedPassword: null });
return setTimeout(() => this.handleError(e, 'setAvatarFromService', 'changing_avatar'), 300);
}
}
await RocketChat.saveUserProfile(params, customFields);
this.setState({ saving: false });
setTimeout(() => {
showToast(I18n.t('Profile_saved_successfully'));
this.init();
}, 300);
} catch (e) {
this.setState({ saving: false, typedPassword: null });
setTimeout(() => {
this.handleError(e, 'saveUserProfile', 'saving_profile');
}, 300);
}
}
setAvatar = (avatar) => {
this.setState({ avatar });
}
resetAvatar = async() => {
try {
await RocketChat.resetAvatar();
showToast(I18n.t('Avatar_changed_successfully'));
this.init();
} catch (e) {
this.handleError(e, 'resetAvatar', 'changing_avatar');
}
}
pickImage = () => {
const options = {
title: I18n.t('Select_Avatar')
};
ImagePicker.showImagePicker(options, async(response) => {
if (response.didCancel) {
console.warn('User cancelled image picker');
} else if (response.error) {
log('ImagePicker Error', response.error);
} else {
this.setAvatar({ url: response.uri, data: `data:image/jpeg;base64,${ response.data }`, service: 'upload' });
}
});
}
renderAvatarButton = ({
key, child, onPress, disabled = false
}) => (
<Touch
key={key}
testID={key}
onPress={onPress}
underlayColor='rgba(255, 255, 255, 0.5)'
activeOpacity={0.3}
disabled={disabled}
>
<View
style={[styles.avatarButton, { opacity: disabled ? 0.5 : 1 }]}
>
{child}
</View>
</Touch>
)
renderAvatarButtons = () => (
<View style={styles.avatarButtons}>
{this.renderAvatarButton({
child: <Avatar text={this.props.user.username} size={50} forceInitials />,
onPress: () => this.resetAvatar(),
key: 'profile-view-reset-avatar'
})}
{this.renderAvatarButton({
child: <Icon name='file-upload' size={30} />,
onPress: () => this.pickImage(),
key: 'profile-view-upload-avatar'
})}
{this.renderAvatarButton({
child: <Icon name='link' size={30} />,
onPress: () => this.setAvatar({ url: this.state.avatarUrl, data: this.state.avatarUrl, service: 'url' }),
disabled: !this.state.avatarUrl,
key: 'profile-view-avatar-url-button'
})}
{Object.keys(this.state.avatarSuggestions).map((service) => {
const { url, blob, contentType } = this.state.avatarSuggestions[service];
return this.renderAvatarButton({
key: `profile-view-avatar-${ service }`,
child: <Avatar avatar={url} size={50} />,
onPress: () => this.setAvatar({
url, data: blob, service, contentType
})
});
})}
</View>
);
renderCustomFields = () => {
const { customFields } = this.state;
if (!this.props.Accounts_CustomFields) {
return null;
}
const parsedCustomFields = JSON.parse(this.props.Accounts_CustomFields);
return Object.keys(parsedCustomFields).map((key, index, array) => {
if (parsedCustomFields[key].type === 'select') {
const options = parsedCustomFields[key].options.map(option => ({ label: option, value: option }));
return (
<RNPickerSelect
key={key}
items={options}
onValueChange={(value) => {
const newValue = {};
newValue[key] = value;
this.setState({ customFields: { ...this.state.customFields, ...newValue } });
}}
value={customFields[key]}
>
<RCTextInput
inputRef={(e) => { this[key] = e; }}
label={key}
placeholder={key}
value={customFields[key]}
testID='settings-view-language'
/>
</RNPickerSelect>
);
}
return (
<RCTextInput
inputRef={(e) => { this[key] = e; }}
key={key}
label={key}
placeholder={key}
value={customFields[key]}
onChangeText={(value) => {
const newValue = {};
newValue[key] = value;
this.setState({ customFields: { ...this.state.customFields, ...newValue } });
}}
onSubmitEditing={() => {
if (array.length - 1 > index) {
return this[array[index + 1]].focus();
}
this.avatarUrl.focus();
}}
/>
);
});
} }
render() { render() {
const {
name, username, email, newPassword, avatarUrl, customFields
} = this.state;
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <KeyboardView
<Text>ProfileView</Text> contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<ScrollView
contentContainerStyle={sharedStyles.containerScrollView}
testID='profile-view-list'
{...scrollPersistTaps}
>
<SafeAreaView testID='profile-view'>
<View style={styles.avatarContainer} testID='profile-view-avatar'>
<Avatar
text={username}
avatar={this.state.avatar && this.state.avatar.url}
size={100}
/>
</View> </View>
<RCTextInput
inputRef={(e) => { this.name = e; }}
label={I18n.t('Name')}
placeholder={I18n.t('Name')}
value={name}
onChangeText={value => this.setState({ name: value })}
onSubmitEditing={() => { this.username.focus(); }}
testID='profile-view-name'
/>
<RCTextInput
inputRef={(e) => { this.username = e; }}
label={I18n.t('Username')}
placeholder={I18n.t('Username')}
value={username}
onChangeText={value => this.setState({ username: value })}
onSubmitEditing={() => { this.email.focus(); }}
testID='profile-view-username'
/>
<RCTextInput
inputRef={(e) => { this.email = e; }}
label={I18n.t('Email')}
placeholder={I18n.t('Email')}
value={email}
onChangeText={value => this.setState({ email: value })}
onSubmitEditing={() => { this.newPassword.focus(); }}
testID='profile-view-email'
/>
<RCTextInput
inputRef={(e) => { this.newPassword = e; }}
label={I18n.t('New_Password')}
placeholder={I18n.t('New_Password')}
value={newPassword}
onChangeText={value => this.setState({ newPassword: value })}
onSubmitEditing={() => {
if (Object.keys(customFields).length) {
return this[Object.keys(customFields)[0]].focus();
}
this.avatarUrl.focus();
}}
secureTextEntry
testID='profile-view-new-password'
/>
{this.renderCustomFields()}
<RCTextInput
inputRef={(e) => { this.avatarUrl = e; }}
label={I18n.t('Avatar_Url')}
placeholder={I18n.t('Avatar_Url')}
value={avatarUrl}
onChangeText={value => this.setState({ avatarUrl: value })}
onSubmitEditing={this.submit}
testID='profile-view-avatar-url'
/>
{this.renderAvatarButtons()}
<View style={sharedStyles.alignItemsFlexStart}>
<Button
title={I18n.t('Save_Changes')}
type='primary'
onPress={this.submit}
disabled={!this.formIsChanged()}
testID='profile-view-submit'
/>
</View>
<Loading visible={this.state.saving} />
<Dialog.Container visible={this.state.showPasswordAlert}>
<Dialog.Title>
{I18n.t('Please_enter_your_password')}
</Dialog.Title>
<Dialog.Description>
{I18n.t('For_your_security_you_must_enter_your_current_password_to_continue')}
</Dialog.Description>
<Dialog.Input
onChangeText={value => this.setState({ typedPassword: value })}
secureTextEntry
testID='profile-view-typed-password'
/>
<Dialog.Button label={I18n.t('Cancel')} onPress={this.closePasswordAlert} />
<Dialog.Button label={I18n.t('Save')} onPress={this.submit} />
</Dialog.Container>
</SafeAreaView>
</ScrollView>
</KeyboardView>
); );
} }
} }

View File

@ -0,0 +1,24 @@
import { StyleSheet } from 'react-native';
export default StyleSheet.create({
avatarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10
},
avatarButtons: {
flexWrap: 'wrap',
flexDirection: 'row',
justifyContent: 'flex-start'
},
avatarButton: {
backgroundColor: '#e1e5e8',
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
marginRight: 15,
marginBottom: 15,
borderRadius: 2
}
});

View File

@ -207,10 +207,11 @@ export default class RegisterView extends LoggedView {
<Text style={[styles.loginText, styles.loginTitle]}>{I18n.t('Sign_Up')}</Text> <Text style={[styles.loginText, styles.loginTitle]}>{I18n.t('Sign_Up')}</Text>
{this._renderRegister()} {this._renderRegister()}
{this._renderUsername()} {this._renderUsername()}
{this.props.login.failure && {this.props.login.failure ?
<Text style={styles.error} testID='register-view-error'> <Text style={styles.error} testID='register-view-error'>
{this.props.login.error.reason} {this.props.login.error.reason}
</Text> </Text>
: null
} }
<Loading visible={this.props.login.isFetching} /> <Loading visible={this.props.login.isFetching} />
</SafeAreaView> </SafeAreaView>

View File

@ -381,7 +381,7 @@ export default class RoomActionsView extends LoggedView {
] : [ ] : [
<Icon key='left-icon' name={item.icon} size={24} style={styles.sectionItemIcon} />, <Icon key='left-icon' name={item.icon} size={24} style={styles.sectionItemIcon} />,
<Text key='name' style={styles.sectionItemName}>{ item.name }</Text>, <Text key='name' style={styles.sectionItemName}>{ item.name }</Text>,
item.description && <Text key='description' style={styles.sectionItemDescription}>{ item.description }</Text>, item.description ? <Text key='description' style={styles.sectionItemDescription}>{ item.description }</Text> : null,
<Icon key='right-icon' name='ios-arrow-forward' size={20} style={styles.sectionItemIcon} color='#ccc' /> <Icon key='right-icon' name='ios-arrow-forward' size={20} style={styles.sectionItemIcon} color='#ccc' />
]; ];
return this.renderTouchableItem(subview, item); return this.renderTouchableItem(subview, item);

View File

@ -4,13 +4,6 @@ export default StyleSheet.create({
container: { container: {
backgroundColor: '#F6F7F9' backgroundColor: '#F6F7F9'
}, },
headerButton: {
backgroundColor: 'transparent',
height: 44,
width: 44,
alignItems: 'center',
justifyContent: 'center'
},
sectionItem: { sectionItem: {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
paddingVertical: 16, paddingVertical: 16,

View File

@ -107,8 +107,8 @@ export default class RoomFilesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={loading && <RCActivityIndicator />} ListHeaderComponent={loading ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
/> />
] ]
); );

View File

@ -190,7 +190,7 @@ export default class RoomInfoEditView extends LoggedView {
await this.setState({ saving: false }); await this.setState({ saving: false });
setTimeout(() => { setTimeout(() => {
if (error) { if (error) {
showErrorAlert(I18n.t('There_was_an_error_while_saving_settings')); showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t('saving_settings') }));
} else { } else {
showToast(I18n.t('Settings_succesfully_changed')); showToast(I18n.t('Settings_succesfully_changed'));
} }
@ -266,7 +266,6 @@ export default class RoomInfoEditView extends LoggedView {
{...scrollPersistTaps} {...scrollPersistTaps}
> >
<SafeAreaView testID='room-info-edit-view'> <SafeAreaView testID='room-info-edit-view'>
<View style={sharedStyles.formContainer}>
<RCTextInput <RCTextInput
inputRef={(e) => { this.name = e; }} inputRef={(e) => { this.name = e; }}
label={I18n.t('Name')} label={I18n.t('Name')}
@ -328,7 +327,7 @@ export default class RoomInfoEditView extends LoggedView {
disabled={!this.permissions[PERMISSION_SET_READONLY] || room.broadcast} disabled={!this.permissions[PERMISSION_SET_READONLY] || room.broadcast}
testID='room-info-edit-view-ro' testID='room-info-edit-view-ro'
/> />
{ro && !room.broadcast && {ro && !room.broadcast ?
<SwitchContainer <SwitchContainer
value={reactWhenReadOnly} value={reactWhenReadOnly}
leftLabelPrimary={I18n.t('No_Reactions')} leftLabelPrimary={I18n.t('No_Reactions')}
@ -339,12 +338,14 @@ export default class RoomInfoEditView extends LoggedView {
disabled={!this.permissions[PERMISSION_SET_REACT_WHEN_READONLY]} disabled={!this.permissions[PERMISSION_SET_REACT_WHEN_READONLY]}
testID='room-info-edit-view-react-when-ro' testID='room-info-edit-view-react-when-ro'
/> />
: null
} }
{room.broadcast && {room.broadcast ?
[ [
<Text style={styles.broadcast}>{I18n.t('Broadcast_Channel')}</Text>, <Text style={styles.broadcast}>{I18n.t('Broadcast_Channel')}</Text>,
<View style={styles.divider} /> <View style={styles.divider} />
] ]
: null
} }
<TouchableOpacity <TouchableOpacity
style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]} style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]}
@ -392,7 +393,6 @@ export default class RoomInfoEditView extends LoggedView {
> >
<Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'>{I18n.t('DELETE')}</Text> <Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'>{I18n.t('DELETE')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View>
<Loading visible={this.state.saving} /> <Loading visible={this.state.saving} />
</SafeAreaView> </SafeAreaView>
</ScrollView> </ScrollView>

View File

@ -61,7 +61,7 @@ export default class RoomInfoView extends LoggedView {
accessibilityTraits='button' accessibilityTraits='button'
testID='room-info-view-edit-button' testID='room-info-view-edit-button'
> >
<View style={styles.headerButton}> <View style={sharedStyles.headerButton}>
<MaterialIcon name='edit' size={20} /> <MaterialIcon name='edit' size={20} />
</View> </View>
</Touch> </Touch>
@ -146,7 +146,7 @@ export default class RoomInfoView extends LoggedView {
); );
renderRoles = () => ( renderRoles = () => (
this.state.roles.length > 0 && this.state.roles.length > 0 ?
<View style={styles.item}> <View style={styles.item}>
<Text style={styles.itemLabel}>{I18n.t('Roles')}</Text> <Text style={styles.itemLabel}>{I18n.t('Roles')}</Text>
<View style={styles.rolesContainer}> <View style={styles.rolesContainer}>
@ -157,6 +157,7 @@ export default class RoomInfoView extends LoggedView {
))} ))}
</View> </View>
</View> </View>
: null
) )
renderTimezone = (userId) => { renderTimezone = (userId) => {
@ -210,12 +211,12 @@ export default class RoomInfoView extends LoggedView {
{this.renderAvatar(room, roomUser)} {this.renderAvatar(room, roomUser)}
<View style={styles.roomTitleContainer}>{ getRoomTitle(room) }</View> <View style={styles.roomTitleContainer}>{ getRoomTitle(room) }</View>
</View> </View>
{!this.isDirect() && this.renderItem('description', room)} {!this.isDirect() ? this.renderItem('description', room) : null}
{!this.isDirect() && this.renderItem('topic', room)} {!this.isDirect() ? this.renderItem('topic', room) : null}
{!this.isDirect() && this.renderItem('announcement', room)} {!this.isDirect() ? this.renderItem('announcement', room) : null}
{this.isDirect() && this.renderRoles()} {this.isDirect() ? this.renderRoles() : null}
{this.isDirect() && this.renderTimezone(roomUser._id)} {this.isDirect() ? this.renderTimezone(roomUser._id) : null}
{room.broadcast && this.renderBroadcast()} {room.broadcast ? this.renderBroadcast() : null}
</ScrollView> </ScrollView>
); );
} }

View File

@ -7,13 +7,6 @@ export default StyleSheet.create({
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
padding: 10 padding: 10
}, },
headerButton: {
backgroundColor: 'transparent',
height: 44,
width: 44,
alignItems: 'center',
justifyContent: 'center'
},
item: { item: {
padding: 10, padding: 10,
// borderColor: '#EBEDF1', // borderColor: '#EBEDF1',

View File

@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FlatList, Text, View, TextInput, Vibration } from 'react-native'; import { FlatList, Text, View, TextInput, Vibration, TouchableOpacity } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ActionSheet from 'react-native-actionsheet'; import ActionSheet from 'react-native-actionsheet';
import LoggedView from '../View'; import LoggedView from '../View';
import styles from './styles'; import styles from './styles';
import sharedStyles from '../Styles';
import RoomItem from '../../presentation/RoomItem'; import RoomItem from '../../presentation/RoomItem';
import Touch from '../../utils/touch';
import scrollPersistTaps from '../../utils/scrollPersistTaps'; import scrollPersistTaps from '../../utils/scrollPersistTaps';
import RocketChat from '../../lib/rocketchat'; import RocketChat from '../../lib/rocketchat';
import { goRoom } from '../../containers/routes/NavigationService'; import { goRoom } from '../../containers/routes/NavigationService';
@ -33,19 +33,15 @@ export default class MentionedMessagesView extends LoggedView {
} }
return { return {
headerRight: ( headerRight: (
<Touch <TouchableOpacity
onPress={params.onPressToogleStatus} onPress={params.onPressToogleStatus}
underlayColor='#ffffff'
activeOpacity={0.5}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityTraits='button' accessibilityTraits='button'
style={styles.headerButtonTouchable} style={[sharedStyles.headerButton, styles.headerButton]}
testID='room-members-view-toggle-status' testID='room-members-view-toggle-status'
> >
<View style={styles.headerButton}> <Text>{label}</Text>
<Text style={styles.headerButtonText}>{label}</Text> </TouchableOpacity>
</View>
</Touch>
) )
}; };
}; };

View File

@ -31,18 +31,6 @@ export default StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#444' color: '#444'
}, },
headerButtonTouchable: {
borderRadius: 4
},
headerButton: {
padding: 6,
backgroundColor: 'transparent',
alignItems: 'center',
justifyContent: 'center'
},
headerButtonText: {
color: '#292E35'
},
searchBoxView: { searchBoxView: {
backgroundColor: '#eee' backgroundColor: '#eee'
}, },
@ -53,5 +41,9 @@ export default StyleSheet.create({
padding: 5, padding: 5,
paddingLeft: 10, paddingLeft: 10,
color: '#aaa' color: '#aaa'
},
headerButton: {
marginRight: 9,
alignItems: 'flex-end'
} }
}); });

View File

@ -14,6 +14,7 @@ import { closeRoom } from '../../../actions/room';
import log from '../../../utils/log'; import log from '../../../utils/log';
import RoomTypeIcon from '../../../containers/RoomTypeIcon'; import RoomTypeIcon from '../../../containers/RoomTypeIcon';
import I18n from '../../../i18n'; import I18n from '../../../i18n';
import sharedStyles from '../../Styles';
const title = (offline, connecting, authenticating, logged) => { const title = (offline, connecting, authenticating, logged) => {
if (offline) { if (offline) {
@ -159,7 +160,7 @@ export default class RoomHeaderView extends React.PureComponent {
</Text> </Text>
</View> </View>
{ t && <Text style={styles.userStatus} allowFontScaling={false} numberOfLines={1}>{t}</Text>} { t ? <Text style={styles.userStatus} allowFontScaling={false} numberOfLines={1}>{t}</Text> : null}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@ -169,7 +170,7 @@ export default class RoomHeaderView extends React.PureComponent {
renderRight = () => ( renderRight = () => (
<View style={styles.right}> <View style={styles.right}>
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={sharedStyles.headerButton}
onPress={() => { onPress={() => {
try { try {
RocketChat.toggleFavorite(this.state.room.rid, this.state.room.f); RocketChat.toggleFavorite(this.state.room.rid, this.state.room.f);
@ -189,7 +190,7 @@ export default class RoomHeaderView extends React.PureComponent {
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={sharedStyles.headerButton}
onPress={() => this.props.navigation.navigate({ key: 'RoomActions', routeName: 'RoomActions', params: { rid: this.state.room.rid } })} onPress={() => this.props.navigation.navigate({ key: 'RoomActions', routeName: 'RoomActions', params: { rid: this.state.room.rid } })}
accessibilityLabel={I18n.t('Room_actions')} accessibilityLabel={I18n.t('Room_actions')}
accessibilityTraits='button' accessibilityTraits='button'

View File

@ -40,13 +40,6 @@ export default StyleSheet.create({
right: { right: {
flexDirection: 'row' flexDirection: 'row'
}, },
headerButton: {
backgroundColor: 'transparent',
height: 44,
width: 40,
alignItems: 'center',
justifyContent: 'center'
},
avatar: { avatar: {
marginRight: 5 marginRight: 5
} }

View File

@ -113,8 +113,8 @@ export class ListView extends OldList2 {
// const { renderSectionHeader } = this.props; // const { renderSectionHeader } = this.props;
const header = this.props.renderHeader && this.props.renderHeader(); const header = this.props.renderHeader ? this.props.renderHeader() : null;
const footer = this.props.renderFooter && this.props.renderFooter(); const footer = this.props.renderFooter ? this.props.renderFooter() : null;
// let totalIndex = header ? 1 : 0; // let totalIndex = header ? 1 : 0;
const { data } = this.props; const { data } = this.props;

View File

@ -95,8 +95,12 @@ export default class RoomView extends LoggedView {
} }
requestAnimationFrame(async() => { requestAnimationFrame(async() => {
try {
const result = await RocketChat.loadMessagesForRoom({ rid: this.rid, t: this.state.room.t, latest: lastRowData.ts }); const result = await RocketChat.loadMessagesForRoom({ rid: this.rid, t: this.state.room.t, latest: lastRowData.ts });
this.setState({ end: result < 20 }); this.setState({ end: result < 20 });
} catch (e) {
log('RoomView.onEndReached', e);
}
}); });
} }

View File

@ -13,6 +13,7 @@ import RocketChat from '../../../lib/rocketchat';
import { STATUS_COLORS } from '../../../constants/colors'; import { STATUS_COLORS } from '../../../constants/colors';
import { setSearch } from '../../../actions/rooms'; import { setSearch } from '../../../actions/rooms';
import styles from './styles'; import styles from './styles';
import sharedStyles from '../../Styles';
import log from '../../../utils/log'; import log from '../../../utils/log';
import I18n from '../../../i18n'; import I18n from '../../../i18n';
@ -130,7 +131,7 @@ export default class RoomsListHeaderView extends React.PureComponent {
testID='rooms-list-view-sidebar' testID='rooms-list-view-sidebar'
> >
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={sharedStyles.headerButton}
onPress={() => this.props.navigation.openDrawer()} onPress={() => this.props.navigation.openDrawer()}
> >
<FastImage <FastImage
@ -174,7 +175,7 @@ export default class RoomsListHeaderView extends React.PureComponent {
</Avatar> </Avatar>
<View style={styles.rows}> <View style={styles.rows}>
<Text accessible={false} style={styles.title} ellipsizeMode='tail' numberOfLines={1} allowFontScaling={false}>{this.props.user.username}</Text> <Text accessible={false} style={styles.title} ellipsizeMode='tail' numberOfLines={1} allowFontScaling={false}>{this.props.user.username}</Text>
{ t && <Text accessible={false} style={styles.status_text} ellipsizeMode='tail' numberOfLines={1} allowFontScaling={false}>{t}</Text>} { t ? <Text accessible={false} style={styles.status_text} ellipsizeMode='tail' numberOfLines={1} allowFontScaling={false}>{t}</Text> : null}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
@ -189,7 +190,7 @@ export default class RoomsListHeaderView extends React.PureComponent {
<View style={styles.right}> <View style={styles.right}>
{Platform.OS === 'android' ? {Platform.OS === 'android' ?
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={sharedStyles.headerButton}
onPress={() => this.onPressSearchButton()} onPress={() => this.onPressSearchButton()}
accessibilityLabel={I18n.t('Search')} accessibilityLabel={I18n.t('Search')}
accessibilityTraits='button' accessibilityTraits='button'
@ -203,7 +204,7 @@ export default class RoomsListHeaderView extends React.PureComponent {
</TouchableOpacity> : null} </TouchableOpacity> : null}
{Platform.OS === 'ios' ? {Platform.OS === 'ios' ?
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={sharedStyles.headerButton}
onPress={() => this.createChannel()} onPress={() => this.createChannel()}
accessibilityLabel={I18n.t('Create_Channel')} accessibilityLabel={I18n.t('Create_Channel')}
accessibilityTraits='button' accessibilityTraits='button'

View File

@ -55,13 +55,6 @@ export default StyleSheet.create({
borderBottomColor: 'rgba(0, 0, 0, .3)', borderBottomColor: 'rgba(0, 0, 0, .3)',
paddingHorizontal: 20 paddingHorizontal: 20
}, },
headerButton: {
backgroundColor: 'transparent',
height: 44,
width: 44,
alignItems: 'center',
justifyContent: 'center'
},
user_status: { user_status: {
position: 'absolute', position: 'absolute',
bottom: -2, bottom: -2,

View File

@ -223,6 +223,6 @@ export default class RoomsListView extends LoggedView {
render = () => ( render = () => (
<View style={styles.container} testID='rooms-list-view'> <View style={styles.container} testID='rooms-list-view'>
{this.renderList()} {this.renderList()}
{Platform.OS === 'android' && this.renderCreateButtons()} {Platform.OS === 'android' ? this.renderCreateButtons() : null}
</View>) </View>)
} }

View File

@ -129,8 +129,8 @@ export default class SearchMessagesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={searching && <RCActivityIndicator />} ListHeaderComponent={searching ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
{...scrollPersistTaps} {...scrollPersistTaps}
/> />
</View> </View>

View File

@ -1,18 +1,134 @@
import React from 'react'; import React from 'react';
import { Text, View } from 'react-native'; import PropTypes from 'prop-types';
import { View, ScrollView, SafeAreaView } from 'react-native';
import RNPickerSelect from 'react-native-picker-select';
import { connect } from 'react-redux';
import LoggedView from '../View'; import LoggedView from '../View';
import RocketChat from '../../lib/rocketchat';
import KeyboardView from '../../presentation/KeyboardView';
import sharedStyles from '../Styles';
import RCTextInput from '../../containers/TextInput';
import scrollPersistTaps from '../../utils/scrollPersistTaps';
import I18n from '../../i18n';
import Button from '../../containers/Button';
import Loading from '../../containers/Loading';
import { showErrorAlert, showToast } from '../../utils/info';
import log from '../../utils/log';
import { setUser } from '../../actions/login';
@connect(state => ({
user: state.login.user
}), dispatch => ({
setUser: params => dispatch(setUser(params))
}))
export default class SettingsView extends LoggedView { export default class SettingsView extends LoggedView {
static propTypes = {
user: PropTypes.object,
setUser: PropTypes.func
};
constructor(props) { constructor(props) {
super('SettingsView', props); super('SettingsView', props);
this.state = {
placeholder: {},
language: props.user ? props.user.language : 'en',
languages: [{
label: 'English',
value: 'en'
}],
saving: false
};
}
formIsChanged = () => {
const { language } = this.state;
const { user } = this.props;
return !(user.language === language);
}
submit = async() => {
this.setState({ saving: true });
const {
language
} = this.state;
const { user } = this.props;
if (!this.formIsChanged()) {
return;
}
const params = {};
// language
if (user.language !== language) {
params.language = language;
}
try {
await RocketChat.saveUserPreferences(params);
this.props.setUser({ language: params.language });
this.props.navigation.setParams({ title: I18n.t('Settings') });
this.setState({ saving: false });
setTimeout(() => {
showToast(I18n.t('Preferences_saved'));
}, 300);
} catch (e) {
this.setState({ saving: false });
setTimeout(() => {
if (e && e.error) {
return showErrorAlert(I18n.t(e.error, e.details));
}
showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t('saving_preferences') }));
log('saveUserPreferences', e);
}, 300);
}
} }
render() { render() {
const { language, languages, placeholder } = this.state;
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> <KeyboardView
<Text>SettingsView</Text> contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<ScrollView
contentContainerStyle={sharedStyles.containerScrollView}
testID='settings-view-list'
{...scrollPersistTaps}
>
<SafeAreaView testID='settings-view'>
<RNPickerSelect
items={languages}
onValueChange={(value) => {
this.setState({ language: value });
}}
value={language}
placeholder={placeholder}
>
<RCTextInput
inputRef={(e) => { this.name = e; }}
label={I18n.t('Language')}
placeholder={I18n.t('Language')}
value={language}
testID='settings-view-language'
/>
</RNPickerSelect>
<View style={sharedStyles.alignItemsFlexStart}>
<Button
title={I18n.t('Save_Changes')}
type='primary'
onPress={this.submit}
disabled={!this.formIsChanged()}
testID='settings-view-button'
/>
</View> </View>
<Loading visible={this.state.saving} />
</SafeAreaView>
</ScrollView>
</KeyboardView>
); );
} }
} }

View File

@ -109,8 +109,8 @@ export default class SnippetedMessagesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={loading && <RCActivityIndicator />} ListHeaderComponent={loading ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
/> />
] ]
); );

View File

@ -133,8 +133,8 @@ export default class StarredMessagesView extends LoggedView {
style={styles.list} style={styles.list}
keyExtractor={item => item._id} keyExtractor={item => item._id}
onEndReached={this.moreData} onEndReached={this.moreData}
ListHeaderComponent={loading && <RCActivityIndicator />} ListHeaderComponent={loading ? <RCActivityIndicator /> : null}
ListFooterComponent={loadingMore && <RCActivityIndicator />} ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null}
/>, />,
<ActionSheet <ActionSheet
key='starred-messages-view-action-sheet' key='starred-messages-view-action-sheet'

View File

@ -195,5 +195,12 @@ export default StyleSheet.create({
width: 50, width: 50,
height: 50, height: 50,
marginVertical: 25 marginVertical: 25
},
headerButton: {
backgroundColor: 'transparent',
height: 44,
width: 44,
alignItems: 'center',
justifyContent: 'center'
} }
}); });

View File

@ -2,7 +2,7 @@ const {
device, expect, element, by, waitFor device, expect, element, by, waitFor
} = require('detox'); } = require('detox');
const { takeScreenshot } = require('./helpers/screenshot'); const { takeScreenshot } = require('./helpers/screenshot');
const { logout, navigateToLogin } = require('./helpers/app'); const { logout, navigateToLogin, login } = require('./helpers/app');
const data = require('./data'); const data = require('./data');
describe('Broadcast room', () => { describe('Broadcast room', () => {
@ -99,4 +99,13 @@ describe('Broadcast room', () => {
afterEach(async() => { afterEach(async() => {
takeScreenshot(); takeScreenshot();
}); });
after(async() => {
// log back as main test user and left screen on RoomsListView
await element(by.id('header-back')).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(2000);
await logout();
await navigateToLogin();
await login();
})
}); });

115
e2e/12-profile.spec.js Normal file
View File

@ -0,0 +1,115 @@
const {
device, expect, element, by, waitFor
} = require('detox');
const { takeScreenshot } = require('./helpers/screenshot');
const { logout, navigateToLogin, login } = require('./helpers/app');
const data = require('./data');
const scrollDown = 200;
describe('Profile screen', () => {
before(async() => {
await element(by.id('rooms-list-view-sidebar')).tap();
await waitFor(element(by.id('sidebar'))).toBeVisible().withTimeout(2000);
await waitFor(element(by.id('sidebar-profile'))).toBeVisible().withTimeout(2000);
await expect(element(by.id('sidebar-profile'))).toBeVisible();
await element(by.id('sidebar-profile')).tap();
await waitFor(element(by.id('profile-view'))).toBeVisible().withTimeout(2000);
});
describe('Render', async() => {
it('should have profile view', async() => {
await expect(element(by.id('profile-view'))).toBeVisible();
});
it('should have avatar', async() => {
await expect(element(by.id('profile-view-avatar')).atIndex(0)).toBeVisible();
});
it('should have name', async() => {
await expect(element(by.id('profile-view-name'))).toBeVisible();
});
it('should have username', async() => {
await expect(element(by.id('profile-view-username'))).toBeVisible();
});
it('should have email', async() => {
await expect(element(by.id('profile-view-email'))).toExist();
});
it('should have new password', async() => {
await expect(element(by.id('profile-view-new-password'))).toBeVisible();
});
it('should have avatar url', async() => {
await expect(element(by.id('profile-view-avatar-url'))).toBeVisible();
});
it('should have reset avatar button', async() => {
await waitFor(element(by.id('profile-view-reset-avatar'))).toBeVisible().whileElement(by.id('profile-view-list')).scroll(scrollDown, 'down');
await expect(element(by.id('profile-view-reset-avatar'))).toBeVisible();
});
it('should have upload avatar button', async() => {
await waitFor(element(by.id('profile-view-upload-avatar'))).toBeVisible().whileElement(by.id('profile-view-list')).scroll(scrollDown, 'down');
await expect(element(by.id('profile-view-upload-avatar'))).toBeVisible();
});
it('should have avatar url button', async() => {
await waitFor(element(by.id('profile-view-avatar-url-button'))).toBeVisible().whileElement(by.id('profile-view-list')).scroll(scrollDown, 'down');
await expect(element(by.id('profile-view-avatar-url-button'))).toBeVisible();
});
it('should have submit button', async() => {
await waitFor(element(by.id('profile-view-submit'))).toBeVisible().whileElement(by.id('profile-view-list')).scroll(scrollDown, 'down');
await expect(element(by.id('profile-view-submit'))).toBeVisible();
});
after(async() => {
takeScreenshot();
});
});
describe('Usage', async() => {
it('should change name and username', async() => {
await element(by.id('profile-view-list')).swipe('down');
await element(by.id('profile-view-name')).replaceText(`${ data.user }new`);
await element(by.id('profile-view-username')).replaceText(`${ data.user }new`);
await element(by.id('profile-view-list')).swipe('up');
await element(by.id('profile-view-submit')).tap();
await waitFor(element(by.text('Profile saved successfully!'))).toBeVisible().withTimeout(60000);
await expect(element(by.text('Profile saved successfully!'))).toBeVisible();
await waitFor(element(by.text('Profile saved successfully!'))).toBeNotVisible().withTimeout(10000);
await expect(element(by.text('Profile saved successfully!'))).toBeNotVisible();
});
it('should change email and password', async() => {
await element(by.id('profile-view-email')).replaceText(`test${ data.email }`);
await element(by.id('profile-view-new-password')).replaceText(`${ data.password }new`);
await element(by.id('profile-view-submit')).tap();
await waitFor(element(by.id('profile-view-typed-password'))).toBeVisible().withTimeout(10000);
await expect(element(by.id('profile-view-typed-password'))).toBeVisible();
await element(by.id('profile-view-typed-password')).replaceText(`${ data.password }`);
await element(by.text('Save')).tap();
await waitFor(element(by.text('Profile saved successfully!'))).toBeVisible().withTimeout(60000);
await expect(element(by.text('Profile saved successfully!'))).toBeVisible();
await waitFor(element(by.text('Profile saved successfully!'))).toBeNotVisible().withTimeout(10000);
await expect(element(by.text('Profile saved successfully!'))).toBeNotVisible();
});
it('should reset avatar', async() => {
await element(by.id('profile-view-list')).swipe('up');
await element(by.id('profile-view-reset-avatar')).tap();
await waitFor(element(by.text('Avatar changed successfully!'))).toBeVisible().withTimeout(60000);
await expect(element(by.text('Avatar changed successfully!'))).toBeVisible();
await waitFor(element(by.text('Avatar changed successfully!'))).toBeNotVisible().withTimeout(10000);
await expect(element(by.text('Avatar changed successfully!'))).toBeNotVisible();
});
after(async() => {
takeScreenshot();
});
});
});

38
package-lock.json generated
View File

@ -10764,6 +10764,11 @@
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.5.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.5.tgz",
"integrity": "sha512-aUnNwqMOXw3yvErjMPSQu6qIIzUmT1e5KcU1OZxRDU1g/am6mzBvcrmLAYwzmB59BHPrh5/tKaiF4OPhqRWESQ==" "integrity": "sha512-aUnNwqMOXw3yvErjMPSQu6qIIzUmT1e5KcU1OZxRDU1g/am6mzBvcrmLAYwzmB59BHPrh5/tKaiF4OPhqRWESQ=="
}, },
"js-sha256": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz",
"integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA=="
},
"js-tokens": { "js-tokens": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
@ -11116,6 +11121,11 @@
"resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz",
"integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U="
}, },
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
},
"lodash.isplainobject": { "lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@ -14980,6 +14990,26 @@
"prop-types": "15.6.1" "prop-types": "15.6.1"
} }
}, },
"react-native-dialog": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-native-dialog/-/react-native-dialog-4.0.0.tgz",
"integrity": "sha512-BQ2nR2ISDohgSZ/9V34o66FbuuIlJvjOb8FXMoc69aP+fuZt9JA0AiPn2kjQpryfQtTMnFNjcPTDJjXLylSLsw==",
"requires": {
"prop-types": "15.6.1",
"react-native-modal": "5.4.0"
},
"dependencies": {
"react-native-modal": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-native-modal/-/react-native-modal-5.4.0.tgz",
"integrity": "sha512-Bvq4FQPMAFijqjqNX6TxLgKOwdbruM6GvFwF9rb+mowbaFZVoYbHTKLaAbdPlrblgaZKWyOuuxBUoDx41+Xktg==",
"requires": {
"prop-types": "15.6.1",
"react-native-animatable": "1.2.4"
}
}
}
},
"react-native-dismiss-keyboard": { "react-native-dismiss-keyboard": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-native-dismiss-keyboard/-/react-native-dismiss-keyboard-1.0.0.tgz", "resolved": "https://registry.npmjs.org/react-native-dismiss-keyboard/-/react-native-dismiss-keyboard-1.0.0.tgz",
@ -15127,6 +15157,14 @@
"prop-types": "15.6.1" "prop-types": "15.6.1"
} }
}, },
"react-native-picker-select": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/react-native-picker-select/-/react-native-picker-select-3.1.1.tgz",
"integrity": "sha512-zuASTVjdW9fkT1NXMGguLwL2bmiZH0AXATAAKPAS/Rqu5/4GRhwJ+HFwnSL+rGYaGTh4Q2vMlox4cmfSv0IIFQ==",
"requires": {
"lodash.isequal": "4.5.0"
}
},
"react-native-push-notification": { "react-native-push-notification": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/react-native-push-notification/-/react-native-push-notification-3.0.2.tgz", "resolved": "https://registry.npmjs.org/react-native-push-notification/-/react-native-push-notification-3.0.2.tgz",

View File

@ -33,6 +33,7 @@
"deep-equal": "^1.0.1", "deep-equal": "^1.0.1",
"ejson": "^2.1.2", "ejson": "^2.1.2",
"js-base64": "^2.4.5", "js-base64": "^2.4.5",
"js-sha256": "^0.9.0",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"markdown-it-flowdock": "^0.3.7", "markdown-it-flowdock": "^0.3.7",
"moment": "^2.22.2", "moment": "^2.22.2",
@ -44,6 +45,7 @@
"react-native-action-button": "^2.8.3", "react-native-action-button": "^2.8.3",
"react-native-actionsheet": "^2.4.2", "react-native-actionsheet": "^2.4.2",
"react-native-audio": "^4.1.3", "react-native-audio": "^4.1.3",
"react-native-dialog": "^4.0.0",
"react-native-fabric": "^0.5.1", "react-native-fabric": "^0.5.1",
"react-native-fast-image": "^4.0.14", "react-native-fast-image": "^4.0.14",
"react-native-fetch-blob": "^0.10.8", "react-native-fetch-blob": "^0.10.8",
@ -56,6 +58,7 @@
"react-native-meteor": "^1.3.0", "react-native-meteor": "^1.3.0",
"react-native-modal": "^6.1.0", "react-native-modal": "^6.1.0",
"react-native-optimized-flatlist": "^1.0.4", "react-native-optimized-flatlist": "^1.0.4",
"react-native-picker-select": "^3.1.1",
"react-native-push-notification": "^3.0.1", "react-native-push-notification": "^3.0.1",
"react-native-responsive-ui": "^1.1.1", "react-native-responsive-ui": "^1.1.1",
"react-native-safari-view": "^2.1.0", "react-native-safari-view": "^2.1.0",