From da173275cef2ed2473c7b3eba13f6759000f8fe4 Mon Sep 17 00:00:00 2001 From: Diego Mello <diegolmello@gmail.com> Date: Tue, 12 Jun 2018 22:33:00 -0300 Subject: [PATCH] [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')" --- README.md | 6 + __tests__/__snapshots__/RoomItem.js.snap | 6 +- .../__snapshots__/Storyshots.test.js.snap | 30 +- app/actions/login.js | 6 +- app/containers/Avatar.js | 81 +++- app/containers/MessageBox/index.js | 11 +- app/containers/Sidebar.js | 49 +- app/containers/TextInput.js | 8 +- app/containers/message/Reply.js | 7 +- app/containers/message/index.js | 3 +- app/containers/routes/AuthRoutes.js | 29 +- app/i18n/locales/en.js | 92 +++- app/lib/ddp.js | 6 +- app/lib/realm.js | 6 +- app/lib/rocketchat.js | 60 ++- app/sagas/init.js | 4 +- app/sagas/login.js | 11 + app/views/ForgotPasswordView.js | 40 +- app/views/LoginSignupView.js | 21 +- app/views/LoginView.js | 2 +- app/views/MentionedMessagesView/index.js | 4 +- app/views/PinnedMessagesView/index.js | 4 +- app/views/ProfileView/index.js | 426 +++++++++++++++++- app/views/ProfileView/styles.js | 24 + app/views/RegisterView.js | 3 +- app/views/RoomActionsView/index.js | 2 +- app/views/RoomActionsView/styles.js | 7 - app/views/RoomFilesView/index.js | 4 +- app/views/RoomInfoEditView/index.js | 230 +++++----- app/views/RoomInfoView/index.js | 35 +- app/views/RoomInfoView/styles.js | 7 - app/views/RoomMembersView/index.js | 16 +- app/views/RoomMembersView/styles.js | 16 +- app/views/RoomView/Header/index.js | 7 +- app/views/RoomView/Header/styles.js | 7 - app/views/RoomView/ListView.js | 4 +- app/views/RoomView/index.js | 8 +- app/views/RoomsListView/Header/index.js | 9 +- app/views/RoomsListView/Header/styles.js | 7 - app/views/RoomsListView/index.js | 2 +- app/views/SearchMessagesView/index.js | 4 +- app/views/SettingsView/index.js | 124 ++++- app/views/SnippetedMessagesView/index.js | 4 +- app/views/StarredMessagesView/index.js | 4 +- app/views/Styles.js | 7 + e2e/11-broadcast.spec.js | 11 +- e2e/12-profile.spec.js | 115 +++++ package-lock.json | 38 ++ package.json | 3 + 49 files changed, 1279 insertions(+), 331 deletions(-) create mode 100644 app/views/ProfileView/styles.js create mode 100644 e2e/12-profile.spec.js diff --git a/README.md b/README.md index 62d11f3b9..1ee828a97 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ **Supported Server Versions:** 0.58.0+ (We are working to support earlier versions) +# Download +[](https://play.google.com/store/apps/details?id=chat.rocket.reactnative) + +Note: If you want to try iOS version, send us an email to testflight@rocket.chat and we'll add you to TestFlight users. + + # Installing dependencies Follow the [React Native Getting Started Guide](https://facebook.github.io/react-native/docs/getting-started.html) for detailed instructions on setting up your local machine for development. diff --git a/__tests__/__snapshots__/RoomItem.js.snap b/__tests__/__snapshots__/RoomItem.js.snap index 8b1bf18ed..988f57af1 100644 --- a/__tests__/__snapshots__/RoomItem.js.snap +++ b/__tests__/__snapshots__/RoomItem.js.snap @@ -585,7 +585,7 @@ exports[`render unread +999 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/name", + "uri": "/avatar/name?random=0", } } style={ @@ -835,7 +835,7 @@ exports[`render unread 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/name", + "uri": "/avatar/name?random=0", } } style={ @@ -1085,7 +1085,7 @@ exports[`renders correctly 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/name", + "uri": "/avatar/name?random=0", } } style={ diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 7dc94d89e..86fba1e32 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -62,7 +62,7 @@ exports[`Storyshots Avatar avatar 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/test", + "uri": "/avatar/test?random=0", } } style={ @@ -136,7 +136,7 @@ exports[`Storyshots Avatar avatar 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/aa", + "uri": "/avatar/aa?random=0", } } style={ @@ -210,7 +210,7 @@ exports[`Storyshots Avatar avatar 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/bb", + "uri": "/avatar/bb?random=0", } } style={ @@ -284,7 +284,7 @@ exports[`Storyshots Avatar avatar 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/test", + "uri": "/avatar/test?random=0", } } style={ @@ -393,7 +393,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/rocket.cat", + "uri": "/avatar/rocket.cat?random=0", } } style={ @@ -615,7 +615,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/rocket.cat", + "uri": "/avatar/rocket.cat?random=0", } } style={ @@ -841,7 +841,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/rocket.cat", + "uri": "/avatar/rocket.cat?random=0", } } style={ @@ -1086,7 +1086,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries", + "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0", } } style={ @@ -1335,7 +1335,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries", + "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0", } } style={ @@ -1580,7 +1580,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries", + "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0", } } style={ @@ -1825,7 +1825,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries", + "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0", } } style={ @@ -2070,7 +2070,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries", + "uri": "/avatar/Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries?random=0", } } style={ @@ -2315,7 +2315,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/W", + "uri": "/avatar/W?random=0", } } style={ @@ -2537,7 +2537,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/WW", + "uri": "/avatar/WW?random=0", } } style={ @@ -2759,7 +2759,7 @@ exports[`Storyshots Channel Cell Direct Messages 1`] = ` source={ Object { "priority": "high", - "uri": "/avatar/", + "uri": "/avatar/?random=0", } } style={ diff --git a/app/actions/login.js b/app/actions/login.js index 875c46706..e9e332b36 100644 --- a/app/actions/login.js +++ b/app/actions/login.js @@ -120,8 +120,10 @@ export function forgotPasswordFailure(err) { export function setUser(action) { return { - type: types.USER.SET, - ...action + // do not change this params order + // since we use spread operator, sometimes `type` is overriden + ...action, + type: types.USER.SET }; } diff --git a/app/containers/Avatar.js b/app/containers/Avatar.js index 3e49a100e..42b52c794 100644 --- a/app/containers/Avatar.js +++ b/app/containers/Avatar.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { StyleSheet, Text, View, ViewPropTypes } from 'react-native'; import FastImage from 'react-native-fast-image'; import avatarInitialsAndColor from '../utils/avatarInitialsAndColor'; +import database from '../lib/realm'; const styles = StyleSheet.create({ iconContainer: { @@ -26,17 +27,78 @@ export default class Avatar extends React.PureComponent { static propTypes = { style: ViewPropTypes.style, baseUrl: PropTypes.string, - text: PropTypes.string.isRequired, + text: PropTypes.string, avatar: PropTypes.string, size: PropTypes.number, borderRadius: PropTypes.number, type: PropTypes.string, - children: PropTypes.object + children: PropTypes.object, + forceInitials: PropTypes.bool }; - state = { showInitials: true }; + static defaultProps = { + text: '', + size: 25, + type: 'd', + borderRadius: 2, + forceInitials: false + }; + state = { showInitials: true, user: {} }; + + componentDidMount() { + const { text, type } = this.props; + if (type === 'd') { + this.users = this.userQuery(text); + this.users.addListener(this.update); + this.update(); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.text !== this.props.text && nextProps.type === 'd') { + if (this.users) { + this.users.removeAllListeners(); + } + this.users = this.userQuery(nextProps.text); + this.users.addListener(this.update); + this.update(); + } + } + + componentWillUnmount() { + if (this.users) { + this.users.removeAllListeners(); + } + } + + get avatarVersion() { + return (this.state.user && this.state.user.avatarVersion) || 0; + } + + /** FIXME: Workaround + * While we don't have containers/components structure, this is breaking tests. + * In that case, avatar would be a component, it would receive an `avatarVersion` param + * and we would have a avatar container in charge of making queries. + * Also, it would make possible to write unit tests like these. + */ + userQuery = (username) => { + if (database && database.databases && database.databases.activeDB) { + return database.objects('users').filtered('username = $0', username); + } + return { + addListener: () => {}, + removeAllListeners: () => {} + }; + } + + update = () => { + if (this.users.length) { + this.setState({ user: this.users[0] }); + } + } + render() { const { - text = '', size = 25, baseUrl, borderRadius = 2, style, avatar, type = 'd' + text, size, baseUrl, borderRadius, style, avatar, type, forceInitials } = this.props; const { initials, color } = avatarInitialsAndColor(`${ text }`); @@ -60,9 +122,9 @@ export default class Avatar extends React.PureComponent { let image; - if (type === 'd') { - const uri = avatar || `${ baseUrl }/avatar/${ text }`; - image = uri && ( + if (type === 'd' && !forceInitials) { + const uri = avatar || `${ baseUrl }/avatar/${ text }?random=${ this.avatarVersion }`; + image = uri ? ( <FastImage style={[styles.avatar, avatarStyle]} source={{ @@ -70,18 +132,19 @@ export default class Avatar extends React.PureComponent { priority: FastImage.priority.high }} /> - ); + ) : null; } return ( <View style={[styles.iconContainer, iconContainerStyle, style]}> - {this.state.showInitials && + {this.state.showInitials ? <Text style={[styles.avatarInitials, avatarInitialsStyle]} allowFontScaling={false} > {initials} </Text> + : null } {image} {this.props.children} diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index fea1c26b6..25925ad5f 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -172,7 +172,7 @@ export default class MessageBox extends React.PureComponent { maxWidth: 1960, quality: 0.8 }; - ImagePicker.showImagePicker(options, (response) => { + ImagePicker.showImagePicker(options, async(response) => { if (response.didCancel) { console.warn('User cancelled image picker'); } else if (response.error) { @@ -185,7 +185,11 @@ export default class MessageBox extends React.PureComponent { // description: '', store: 'Uploads' }; - RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data); + try { + await RocketChat.sendFileMessage(this.props.rid, fileInfo, response.data); + } catch (e) { + log('addFile', e); + } } }); } @@ -459,6 +463,7 @@ export default class MessageBox extends React.PureComponent { style={{ margin: 8 }} text={item.username || item.name} size={30} + type={item.username ? 'd' : 'c'} />, <Text key='mention-item-name'>{ item.username || item.name }</Text> ] @@ -477,7 +482,7 @@ export default class MessageBox extends React.PureComponent { style={styles.mentionList} data={mentions} renderItem={({ item }) => this.renderMentionItem(item)} - keyExtractor={item => item._id || item} + keyExtractor={item => item._id || item.username || item} keyboardShouldPersistTaps='always' /> </View> diff --git a/app/containers/Sidebar.js b/app/containers/Sidebar.js index e2acce4c7..ea6cc2f9d 100644 --- a/app/containers/Sidebar.js +++ b/app/containers/Sidebar.js @@ -95,26 +95,20 @@ export default class Sidebar extends Component { super(props); this.state = { servers: [], - status: [{ - id: 'online', - name: I18n.t('Online') - }, { - id: 'busy', - name: I18n.t('Busy') - }, { - id: 'away', - name: I18n.t('Away') - }, { - id: 'offline', - name: I18n.t('Invisible') - }], showServers: false }; } componentDidMount() { - database.databases.serversDB.addListener('change', this.updateState); this.setState(this.getState()); + this.setStatus(); + database.databases.serversDB.addListener('change', this.updateState); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.user && this.props.user && this.props.user.language !== nextProps.user.language) { + this.setStatus(); + } } componentWillUnmount() { @@ -126,6 +120,26 @@ export default class Sidebar extends Component { this.closeDrawer(); } + setStatus = () => { + setTimeout(() => { + this.setState({ + status: [{ + id: 'online', + name: I18n.t('Online') + }, { + id: 'busy', + name: I18n.t('Busy') + }, { + id: 'away', + name: I18n.t('Away') + }, { + id: 'offline', + name: I18n.t('Invisible') + }] + }); + }); + } + getState = () => ({ servers: database.databases.serversDB.objects('servers') }) @@ -153,6 +167,8 @@ export default class Sidebar extends Component { const { navigate } = this.props.navigation; if (!this.isRouteFocused(route)) { navigate(route); + } else { + this.closeDrawer(); } } @@ -211,6 +227,7 @@ export default class Sidebar extends Component { this.toggleServers(); if (this.props.server !== item.id) { this.props.selectServer(item.id); + this.props.navigation.navigate('RoomsList'); } }, testID: `sidebar-${ item.id }` @@ -324,8 +341,8 @@ export default class Sidebar extends Component { {this.renderSeparator('separator-header')} - {!this.state.showServers && this.renderNavigation()} - {this.state.showServers && this.renderServers()} + {!this.state.showServers ? this.renderNavigation() : null} + {this.state.showServers ? this.renderServers() : null} </SafeAreaView> </ScrollView> ); diff --git a/app/containers/TextInput.js b/app/containers/TextInput.js index 885b93022..7e85e553c 100644 --- a/app/containers/TextInput.js +++ b/app/containers/TextInput.js @@ -105,7 +105,7 @@ export default class RCTextInput extends React.PureComponent { const { showPassword } = this.state; return ( <View style={[styles.inputContainer, containerStyle]}> - {label && <Text contentDescription={null} accessibilityLabel={null} style={[styles.label, error.error && styles.labelError]}>{label}</Text> } + {label ? <Text contentDescription={null} accessibilityLabel={null} style={[styles.label, error.error && styles.labelError]}>{label}</Text> : null } <View style={styles.wrap}> <TextInput style={[ @@ -126,10 +126,10 @@ export default class RCTextInput extends React.PureComponent { contentDescription={placeholder} {...inputProps} /> - {iconLeft && this.iconLeft(iconLeft)} - {secureTextEntry && this.iconPassword(showPassword ? 'eye-off' : 'eye')} + {iconLeft ? this.iconLeft(iconLeft) : null} + {secureTextEntry ? this.iconPassword(showPassword ? 'eye-off' : 'eye') : null} </View> - {error.error && <Text style={sharedStyles.error}>{error.reason}</Text>} + {error.error ? <Text style={sharedStyles.error}>{error.reason}</Text> : null} </View> ); } diff --git a/app/containers/message/Reply.js b/app/containers/message/Reply.js index 592482bb6..1b42c73c3 100644 --- a/app/containers/message/Reply.js +++ b/app/containers/message/Reply.js @@ -78,7 +78,6 @@ const Reply = ({ attachment, timeFormat }) => { <Avatar text={attachment.author_name} size={16} - avatar={attachment.author_icon} /> ); }; @@ -136,7 +135,11 @@ const Reply = ({ attachment, timeFormat }) => { {renderTitle()} {renderText()} {renderFields()} - {attachment.attachments && attachment.attachments.map(attach => <Reply key={attach.text} attachment={attach} timeFormat={timeFormat} />)} + {attachment.attachments ? + attachment.attachments + .map(attach => <Reply key={attach.text} attachment={attach} timeFormat={timeFormat} />) + : null + } </View> </TouchableOpacity> ); diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 54c31aa49..46152b204 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -358,13 +358,14 @@ export default class Message extends React.Component { {this.renderBroadcastReply()} </View> </View> - {this.state.reactionsModal && + {this.state.reactionsModal ? <ReactionsModal isVisible={this.state.reactionsModal} onClose={this.onClose} reactions={item.reactions} user={this.props.user} /> + : null } </View> </Touch> diff --git a/app/containers/routes/AuthRoutes.js b/app/containers/routes/AuthRoutes.js index 3ded765b3..5361dd2e3 100644 --- a/app/containers/routes/AuthRoutes.js +++ b/app/containers/routes/AuthRoutes.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform } from 'react-native'; +import { Platform, TouchableOpacity } from 'react-native'; import { createStackNavigator, createDrawerNavigator } from 'react-navigation'; import Icon from 'react-native-vector-icons/MaterialIcons'; @@ -22,6 +22,7 @@ import RoomInfoEditView from '../../views/RoomInfoEditView'; import ProfileView from '../../views/ProfileView'; import SettingsView from '../../views/SettingsView'; import I18n from '../../i18n'; +import sharedStyles from '../../views/Styles'; const headerTintColor = '#292E35'; @@ -132,12 +133,24 @@ const AuthRoutes = createStackNavigator( } ); +const MenuButton = ({ navigation, testID }) => ( + <TouchableOpacity + style={sharedStyles.headerButton} + onPress={navigation.toggleDrawer} + accessibilityLabel={I18n.t('Toggle_Drawer')} + accessibilityTraits='button' + testID={testID} + > + <Icon name='menu' size={30} color='#292E35' /> + </TouchableOpacity> +); + const Routes = createDrawerNavigator( { Chats: { screen: AuthRoutes, navigationOptions: { - drawerLabel: 'Chats', + drawerLabel: I18n.t('Chats'), drawerIcon: () => <Icon name='chat-bubble' size={20} /> } }, @@ -146,9 +159,9 @@ const Routes = createDrawerNavigator( ProfileView: { screen: ProfileView, navigationOptions: ({ navigation }) => ({ - title: 'Profile', + title: I18n.t('Profile'), headerTintColor: '#292E35', - headerLeft: <Icon name='menu' size={30} color='#292E35' onPress={() => navigation.toggleDrawer()} /> // TODO: refactor + headerLeft: <MenuButton navigation={navigation} testID='profile-view-sidebar' /> }) } }) @@ -158,9 +171,9 @@ const Routes = createDrawerNavigator( SettingsView: { screen: SettingsView, navigationOptions: ({ navigation }) => ({ - title: 'Settings', + title: I18n.t('Settings'), headerTintColor: '#292E35', - headerLeft: <Icon name='menu' size={30} color='#292E35' onPress={() => navigation.toggleDrawer()} /> // TODO: refactor + headerLeft: <MenuButton navigation={navigation} testID='settings-view-sidebar' /> }) } }) @@ -168,9 +181,7 @@ const Routes = createDrawerNavigator( }, { contentComponent: Sidebar, - navigationOptions: { - drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked' - }, + drawerLockMode: Platform.OS === 'ios' ? 'locked-closed' : 'unlocked', initialRouteName: 'Chats', backBehavior: 'initialRoute' } diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index c66849ca2..a54dcdceb 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -1,6 +1,80 @@ export default { '1_online_member': '1 online member', '1_person_reacted': '1 person reacted', + 'error-action-not-allowed': '{{action}} is not allowed', + 'error-application-not-found': 'Application not found', + 'error-archived-duplicate-name': 'There\'s an archived channel with name {{room_name}}', + 'error-avatar-invalid-url': 'Invalid avatar URL: {{url}}', + 'error-avatar-url-handling': 'Error while handling avatar setting from a URL ({{url}}) for {{username}}', + 'error-cant-invite-for-direct-room': 'Can\'t invite user to direct rooms', + 'error-could-not-change-email': 'Could not change email', + 'error-could-not-change-name': 'Could not change name', + 'error-could-not-change-username': 'Could not change username', + 'error-delete-protected-role': 'Cannot delete a protected role', + 'error-department-not-found': 'Department not found', + 'error-direct-message-file-upload-not-allowed': 'File sharing not allowed in direct messages', + 'error-duplicate-channel-name': 'A channel with name {{channel_name}} exists', + 'error-email-domain-blacklisted': 'The email domain is blacklisted', + 'error-email-send-failed': 'Error trying to send email: {{message}}', + 'error-field-unavailable': '{{field}} is already in use :(', + 'error-file-too-large': 'File is too large', + 'error-importer-not-defined': 'The importer was not defined correctly, it is missing the Import class.', + 'error-input-is-not-a-valid-field': '{{input}} is not a valid {{field}}', + 'error-invalid-actionlink': 'Invalid action link', + 'error-invalid-arguments': 'Invalid arguments', + 'error-invalid-asset': 'Invalid asset', + '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', Add_Reaction: 'Add Reaction', Add_Server: 'Add Server', @@ -21,6 +95,8 @@ export default { 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}}?', Authenticating: 'Authenticating', + Avatar_changed_successfully: 'Avatar changed successfully!', + Avatar_Url: 'Avatar URL', Away: 'Away', Block_user: 'Block user', 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_recording: 'Cancel recording', Cancel: 'Cancel', + changing_avatar: 'changing avatar', Channel_Name: 'Channel Name', Chats: 'Chats', Close_emoji_selector: 'Close emoji selector', @@ -60,6 +137,7 @@ export default { Everyone_can_access_this_channel: 'Everyone can access this channel', Files: 'Files', 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_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', @@ -71,6 +149,7 @@ export default { is_not_a_valid_RocketChat_instance: 'is not a valid Rocket.Chat instance', is_typing: 'is typing', Just_invited_people_can_access_this_channel: 'Just invited people can access this channel', + Language: 'Language', last_message: 'last message', Leave_channel: 'Leave channel', leave: 'leave', @@ -95,6 +174,7 @@ export default { Name: 'Name', New_in_RocketChat_question_mark: 'New in Rocket.Chat?', New_Message: 'New Message', + New_Password: 'New Password', New_Server: 'New Server', No_files: 'No files', No_mentioned_messages: 'No mentioned messages', @@ -121,9 +201,12 @@ export default { Pinned_Messages: 'Pinned Messages', pinned: 'pinned', Pinned: 'Pinned', + Please_enter_your_password: 'Please enter your password', + Preferences_saved: 'Preferences saved!', Privacy_Policy: ' Privacy Policy', Private_Channel: 'Private Channel', Private: 'Private', + Profile_saved_successfully: 'Profile saved successfully!', Profile: 'Profile', Public_Channel: 'Public Channel', Public: 'Public', @@ -151,8 +234,14 @@ export default { Room_Members: 'Room Members', Room_name_changed: 'Room name changed to: {{name}} by {{userBy}}', 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: 'Search', + Select_Avatar: 'Select Avatar', Select_Users: 'Select Users', Send_audio_message: 'Send audio message', Send_message: 'Send message', @@ -177,10 +266,11 @@ export default { tap_to_change_status: 'tap to change status', Tap_to_view_servers_list: 'Tap to view servers list', 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_read_only: 'This room is read only', Timezone: 'Timezone', + Toggle_Drawer: 'Toggle_Drawer', topic: 'topic', Topic: 'Topic', Type_the_channel_name_here: 'Type the channel name here', diff --git a/app/lib/ddp.js b/app/lib/ddp.js index 33e856085..bf4dba72c 100644 --- a/app/lib/ddp.js +++ b/app/lib/ddp.js @@ -144,9 +144,11 @@ export default class Socket extends EventEmitter { try { this.emit('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.emit('logged', result); + // this.emit('logged', result); + this.emit('logged', this._login); return result; } catch (err) { const error = { ...err }; diff --git a/app/lib/realm.js b/app/lib/realm.js index 1d8aedde8..ee7b84640 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -106,11 +106,11 @@ const subscriptionSchema = { const usersSchema = { name: 'users', - primaryKey: '_id', + primaryKey: 'username', properties: { - _id: 'string', username: 'string', - name: { type: 'string', optional: true } + name: { type: 'string', optional: true }, + avatarVersion: { type: 'int', optional: true } } }; diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index b01774a00..420768755 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -91,10 +91,7 @@ const RocketChat = { this.activeUsers = this.activeUsers || {}; const { user } = reduxStore.getState().login; - if (user && user.id === ddpMessage.id) { - if (!ddpMessage.fields) { - reduxStore.dispatch(setUser({ status: 'offline' })); - } + if (ddpMessage.fields && user && user.id === ddpMessage.id) { reduxStore.dispatch(setUser(ddpMessage.fields)); } @@ -107,9 +104,14 @@ const RocketChat = { reduxStore.dispatch(setActiveUser(this.activeUsers)); this._setUserTimer = null; 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) { try { @@ -122,15 +124,11 @@ const RocketChat = { // call /me only one time if (!user.username) { const me = await this.me({ token: user.token, userId: user.id }); - // eslint-disable-next-line - user.username = me.username; + user = { ...user, ...me }; } if (user.username) { const userInfo = await this.userInfo({ token: user.token, userId: user.id }); - user.username = userInfo.user.username; - if (userInfo.user.roles) { - user.roles = userInfo.user.roles; - } + user = { ...user, ...userInfo.user }; } return reduxStore.dispatch(loginSuccess(user)); } catch (e) { @@ -163,7 +161,10 @@ const RocketChat = { this.getRooms().catch(e => log('logged getRooms', e)); 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(() => { reduxStore.dispatch(disconnect()); @@ -184,6 +185,24 @@ const RocketChat = { 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) => { // console.warn('rc.stream-notify-user') // const [type, data] = ddpMessage.fields.args; @@ -804,6 +823,12 @@ const RocketChat = { 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) { return call('saveNotificationSettings', rid, param, value); }, @@ -836,6 +861,15 @@ const RocketChat = { .some(item => mergedRoles.indexOf(item) !== -1); return result; }, {}); + }, + getAvatarSuggestion() { + return call('getAvatarSuggestion'); + }, + resetAvatar() { + return call('resetAvatar'); + }, + setAvatarFromService({ data, contentType = '', service = null }) { + return call('setAvatarFromService', data, contentType, service); } }; diff --git a/app/sagas/init.js b/app/sagas/init.js index b7227e3fd..d911923af 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -20,8 +20,8 @@ const restore = function* restore() { yield put(setServer(currentServer)); const login = yield call([AsyncStorage, 'getItem'], `${ RocketChat.TOKEN_KEY }-${ currentServer }`); - if (login && login.user) { - yield put(setUser(login.user)); + if (login) { + yield put(setUser(JSON.parse(login))); } } diff --git a/app/sagas/login.js b/app/sagas/login.js index 92602eb41..495b4ed5b 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -20,6 +20,7 @@ import { import RocketChat from '../lib/rocketchat'; import * as NavigationService from '../containers/routes/NavigationService'; import log from '../utils/log'; +import I18n from '../i18n'; const getUser = state => state.login; 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() { // yield takeLatest(types.METEOR.SUCCESS, handleLoginWhenServerChanges); // yield takeLatest(types.LOGIN.REQUEST, handleLoginRequest); @@ -184,5 +194,6 @@ const root = function* root() { yield takeLatest(types.LOGOUT, handleLogout); yield takeLatest(types.FORGOT_PASSWORD.REQUEST, handleForgotPasswordRequest); yield takeLatest(types.LOGIN.OPEN, watchLoginOpen); + yield takeLatest(types.USER.SET, handleSetUser); }; export default root; diff --git a/app/views/ForgotPasswordView.js b/app/views/ForgotPasswordView.js index 09d317095..77d4666d4 100644 --- a/app/views/ForgotPasswordView.js +++ b/app/views/ForgotPasswordView.js @@ -78,29 +78,27 @@ export default class ForgotPasswordView extends LoggedView { <ScrollView {...scrollPersistTaps} contentContainerStyle={styles.containerScrollView}> <SafeAreaView testID='forgot-password-view'> <View style={styles.loginView}> - <View style={styles.formContainer}> - <TextInput - inputStyle={this.state.invalidEmail ? { borderColor: 'red' } : {}} - label={I18n.t('Email')} - placeholder={I18n.t('Email')} - keyboardType='email-address' - returnKeyType='next' - onChangeText={email => this.validate(email)} - onSubmitEditing={() => this.resetPassword()} - testID='forgot-password-view-email' - /> - - <View style={styles.alignItemsFlexStart}> - <Button - title={I18n.t('Reset_password')} - type='primary' - onPress={this.resetPassword} - testID='forgot-password-view-submit' - /> - </View> + <TextInput + inputStyle={this.state.invalidEmail ? { borderColor: 'red' } : {}} + label={I18n.t('Email')} + placeholder={I18n.t('Email')} + keyboardType='email-address' + returnKeyType='next' + onChangeText={email => this.validate(email)} + onSubmitEditing={() => this.resetPassword()} + testID='forgot-password-view-email' + /> - {this.props.login.failure && <Text style={styles.error}>{this.props.login.error.reason}</Text>} + <View style={styles.alignItemsFlexStart}> + <Button + title={I18n.t('Reset_password')} + type='primary' + onPress={this.resetPassword} + testID='forgot-password-view-submit' + /> </View> + + {this.props.login.failure ? <Text style={styles.error}>{this.props.login.error.reason}</Text> : null} <Loading visible={this.props.login.isFetching} /> </View> </SafeAreaView> diff --git a/app/views/LoginSignupView.js b/app/views/LoginSignupView.js index 2cd391dcc..c9a55546d 100644 --- a/app/views/LoginSignupView.js +++ b/app/views/LoginSignupView.js @@ -209,61 +209,68 @@ export default class LoginSignupView extends LoggedView { {I18n.t('Or_continue_using_social_accounts')} </Text> <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 style={[sharedStyles.oauthButton, sharedStyles.facebookButton]} onPress={this.onPressFacebook} > <Icon name='facebook' size={20} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Github && this.props.services.github && + {this.props.Accounts_OAuth_Github && this.props.services.github ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.githubButton]} onPress={this.onPressGithub} > <Icon name='github' size={20} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Gitlab && this.props.services.gitlab && + {this.props.Accounts_OAuth_Gitlab && this.props.services.gitlab ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.gitlabButton]} onPress={this.onPressGitlab} > <Icon name='gitlab' size={20} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Google && this.props.services.google && + {this.props.Accounts_OAuth_Google && this.props.services.google ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.googleButton]} onPress={this.onPressGoogle} > <Icon name='google' size={20} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Linkedin && this.props.services.linkedin && + {this.props.Accounts_OAuth_Linkedin && this.props.services.linkedin ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.linkedinButton]} onPress={this.onPressLinkedin} > <Icon name='linkedin' size={20} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Meteor && this.props.services['meteor-developer'] && + {this.props.Accounts_OAuth_Meteor && this.props.services['meteor-developer'] ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.meteorButton]} onPress={this.onPressMeteor} > <MaterialCommunityIcons name='meteor' size={25} color='#ffffff' /> </TouchableOpacity> + : null } - {this.props.Accounts_OAuth_Twitter && this.props.services.twitter && + {this.props.Accounts_OAuth_Twitter && this.props.services.twitter ? <TouchableOpacity style={[sharedStyles.oauthButton, sharedStyles.twitterButton]} onPress={this.onPressTwitter} > <Icon name='twitter' size={20} color='#ffffff' /> </TouchableOpacity> + : null } </View> </View> diff --git a/app/views/LoginView.js b/app/views/LoginView.js index 25b29853d..86d11b69b 100644 --- a/app/views/LoginView.js +++ b/app/views/LoginView.js @@ -135,7 +135,7 @@ export default class LoginView extends LoggedView { </Text> </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} /> </SafeAreaView> </ScrollView> diff --git a/app/views/MentionedMessagesView/index.js b/app/views/MentionedMessagesView/index.js index 226153cb1..7a8b5c479 100644 --- a/app/views/MentionedMessagesView/index.js +++ b/app/views/MentionedMessagesView/index.js @@ -109,8 +109,8 @@ export default class MentionedMessagesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={loading && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={loading ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} /> ] ); diff --git a/app/views/PinnedMessagesView/index.js b/app/views/PinnedMessagesView/index.js index 107be23b0..47befc457 100644 --- a/app/views/PinnedMessagesView/index.js +++ b/app/views/PinnedMessagesView/index.js @@ -133,8 +133,8 @@ export default class PinnedMessagesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={loading && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={loading ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} />, <ActionSheet key='pinned-messages-view-action-sheet' diff --git a/app/views/ProfileView/index.js b/app/views/ProfileView/index.js index d4e11c676..c4c0b8ad7 100644 --- a/app/views/ProfileView/index.js +++ b/app/views/ProfileView/index.js @@ -1,18 +1,436 @@ 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 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 { + static propTypes = { + navigation: PropTypes.object, + user: PropTypes.object, + Accounts_CustomFields: PropTypes.string + }; + constructor(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() { + const { + name, username, email, newPassword, avatarUrl, customFields + } = this.state; return ( - <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> - <Text>ProfileView</Text> - </View> + <KeyboardView + 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> + <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> ); } } diff --git a/app/views/ProfileView/styles.js b/app/views/ProfileView/styles.js new file mode 100644 index 000000000..3ac375c40 --- /dev/null +++ b/app/views/ProfileView/styles.js @@ -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 + } +}); diff --git a/app/views/RegisterView.js b/app/views/RegisterView.js index caeb72613..db3193b4a 100644 --- a/app/views/RegisterView.js +++ b/app/views/RegisterView.js @@ -207,10 +207,11 @@ export default class RegisterView extends LoggedView { <Text style={[styles.loginText, styles.loginTitle]}>{I18n.t('Sign_Up')}</Text> {this._renderRegister()} {this._renderUsername()} - {this.props.login.failure && + {this.props.login.failure ? <Text style={styles.error} testID='register-view-error'> {this.props.login.error.reason} </Text> + : null } <Loading visible={this.props.login.isFetching} /> </SafeAreaView> diff --git a/app/views/RoomActionsView/index.js b/app/views/RoomActionsView/index.js index 52aef0c93..c921ea310 100644 --- a/app/views/RoomActionsView/index.js +++ b/app/views/RoomActionsView/index.js @@ -381,7 +381,7 @@ export default class RoomActionsView extends LoggedView { ] : [ <Icon key='left-icon' name={item.icon} size={24} style={styles.sectionItemIcon} />, <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' /> ]; return this.renderTouchableItem(subview, item); diff --git a/app/views/RoomActionsView/styles.js b/app/views/RoomActionsView/styles.js index 175c04bd3..1d4717f89 100644 --- a/app/views/RoomActionsView/styles.js +++ b/app/views/RoomActionsView/styles.js @@ -4,13 +4,6 @@ export default StyleSheet.create({ container: { backgroundColor: '#F6F7F9' }, - headerButton: { - backgroundColor: 'transparent', - height: 44, - width: 44, - alignItems: 'center', - justifyContent: 'center' - }, sectionItem: { backgroundColor: '#ffffff', paddingVertical: 16, diff --git a/app/views/RoomFilesView/index.js b/app/views/RoomFilesView/index.js index 8a4941cbd..8028275e5 100644 --- a/app/views/RoomFilesView/index.js +++ b/app/views/RoomFilesView/index.js @@ -107,8 +107,8 @@ export default class RoomFilesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={loading && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={loading ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} /> ] ); diff --git a/app/views/RoomInfoEditView/index.js b/app/views/RoomInfoEditView/index.js index 95effd731..58b74f350 100644 --- a/app/views/RoomInfoEditView/index.js +++ b/app/views/RoomInfoEditView/index.js @@ -190,7 +190,7 @@ export default class RoomInfoEditView extends LoggedView { await this.setState({ saving: false }); setTimeout(() => { 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 { showToast(I18n.t('Settings_succesfully_changed')); } @@ -266,133 +266,133 @@ export default class RoomInfoEditView extends LoggedView { {...scrollPersistTaps} > <SafeAreaView testID='room-info-edit-view'> - <View style={sharedStyles.formContainer}> - <RCTextInput - inputRef={(e) => { this.name = e; }} - label={I18n.t('Name')} - value={name} - onChangeText={value => this.setState({ name: value })} - onSubmitEditing={() => { this.description.focus(); }} - error={nameError} - testID='room-info-edit-view-name' - /> - <RCTextInput - inputRef={(e) => { this.description = e; }} - label={I18n.t('Description')} - value={description} - onChangeText={value => this.setState({ description: value })} - onSubmitEditing={() => { this.topic.focus(); }} - testID='room-info-edit-view-description' - /> - <RCTextInput - inputRef={(e) => { this.topic = e; }} - label={I18n.t('Topic')} - value={topic} - onChangeText={value => this.setState({ topic: value })} - onSubmitEditing={() => { this.announcement.focus(); }} - testID='room-info-edit-view-topic' - /> - <RCTextInput - inputRef={(e) => { this.announcement = e; }} - label={I18n.t('Announcement')} - value={announcement} - onChangeText={value => this.setState({ announcement: value })} - onSubmitEditing={() => { this.joinCode.focus(); }} - testID='room-info-edit-view-announcement' - /> - <RCTextInput - inputRef={(e) => { this.joinCode = e; }} - label={I18n.t('Password')} - value={joinCode} - onChangeText={value => this.setState({ joinCode: value })} - onSubmitEditing={this.submit} - secureTextEntry - testID='room-info-edit-view-password' - /> + <RCTextInput + inputRef={(e) => { this.name = e; }} + label={I18n.t('Name')} + value={name} + onChangeText={value => this.setState({ name: value })} + onSubmitEditing={() => { this.description.focus(); }} + error={nameError} + testID='room-info-edit-view-name' + /> + <RCTextInput + inputRef={(e) => { this.description = e; }} + label={I18n.t('Description')} + value={description} + onChangeText={value => this.setState({ description: value })} + onSubmitEditing={() => { this.topic.focus(); }} + testID='room-info-edit-view-description' + /> + <RCTextInput + inputRef={(e) => { this.topic = e; }} + label={I18n.t('Topic')} + value={topic} + onChangeText={value => this.setState({ topic: value })} + onSubmitEditing={() => { this.announcement.focus(); }} + testID='room-info-edit-view-topic' + /> + <RCTextInput + inputRef={(e) => { this.announcement = e; }} + label={I18n.t('Announcement')} + value={announcement} + onChangeText={value => this.setState({ announcement: value })} + onSubmitEditing={() => { this.joinCode.focus(); }} + testID='room-info-edit-view-announcement' + /> + <RCTextInput + inputRef={(e) => { this.joinCode = e; }} + label={I18n.t('Password')} + value={joinCode} + onChangeText={value => this.setState({ joinCode: value })} + onSubmitEditing={this.submit} + secureTextEntry + testID='room-info-edit-view-password' + /> + <SwitchContainer + value={t} + leftLabelPrimary={I18n.t('Public')} + leftLabelSecondary={I18n.t('Everyone_can_access_this_channel')} + rightLabelPrimary={I18n.t('Private')} + rightLabelSecondary={I18n.t('Just_invited_people_can_access_this_channel')} + onValueChange={value => this.setState({ t: value })} + testID='room-info-edit-view-t' + /> + <SwitchContainer + value={ro} + leftLabelPrimary={I18n.t('Colaborative')} + leftLabelSecondary={I18n.t('All_users_in_the_channel_can_write_new_messages')} + rightLabelPrimary={I18n.t('Read_Only')} + rightLabelSecondary={I18n.t('Only_authorized_users_can_write_new_messages')} + onValueChange={value => this.setState({ ro: value })} + disabled={!this.permissions[PERMISSION_SET_READONLY] || room.broadcast} + testID='room-info-edit-view-ro' + /> + {ro && !room.broadcast ? <SwitchContainer - value={t} - leftLabelPrimary={I18n.t('Public')} - leftLabelSecondary={I18n.t('Everyone_can_access_this_channel')} - rightLabelPrimary={I18n.t('Private')} - rightLabelSecondary={I18n.t('Just_invited_people_can_access_this_channel')} - onValueChange={value => this.setState({ t: value })} - testID='room-info-edit-view-t' + value={reactWhenReadOnly} + leftLabelPrimary={I18n.t('No_Reactions')} + leftLabelSecondary={I18n.t('Reactions_are_disabled')} + rightLabelPrimary={I18n.t('Allow_Reactions')} + rightLabelSecondary={I18n.t('Reactions_are_enabled')} + onValueChange={value => this.setState({ reactWhenReadOnly: value })} + disabled={!this.permissions[PERMISSION_SET_REACT_WHEN_READONLY]} + testID='room-info-edit-view-react-when-ro' /> - <SwitchContainer - value={ro} - leftLabelPrimary={I18n.t('Colaborative')} - leftLabelSecondary={I18n.t('All_users_in_the_channel_can_write_new_messages')} - rightLabelPrimary={I18n.t('Read_Only')} - rightLabelSecondary={I18n.t('Only_authorized_users_can_write_new_messages')} - onValueChange={value => this.setState({ ro: value })} - disabled={!this.permissions[PERMISSION_SET_READONLY] || room.broadcast} - testID='room-info-edit-view-ro' - /> - {ro && !room.broadcast && - <SwitchContainer - value={reactWhenReadOnly} - leftLabelPrimary={I18n.t('No_Reactions')} - leftLabelSecondary={I18n.t('Reactions_are_disabled')} - rightLabelPrimary={I18n.t('Allow_Reactions')} - rightLabelSecondary={I18n.t('Reactions_are_enabled')} - onValueChange={value => this.setState({ reactWhenReadOnly: value })} - disabled={!this.permissions[PERMISSION_SET_REACT_WHEN_READONLY]} - testID='room-info-edit-view-react-when-ro' - /> - } - {room.broadcast && - [ - <Text style={styles.broadcast}>{I18n.t('Broadcast_Channel')}</Text>, - <View style={styles.divider} /> - ] - } + : null + } + {room.broadcast ? + [ + <Text style={styles.broadcast}>{I18n.t('Broadcast_Channel')}</Text>, + <View style={styles.divider} /> + ] + : null + } + <TouchableOpacity + style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]} + onPress={this.submit} + disabled={!this.formIsChanged()} + testID='room-info-edit-view-submit' + > + <Text style={sharedStyles.button} accessibilityTraits='button'>{I18n.t('SAVE')}</Text> + </TouchableOpacity> + <View style={{ flexDirection: 'row' }}> <TouchableOpacity - style={[sharedStyles.buttonContainer, !this.formIsChanged() && styles.buttonContainerDisabled]} - onPress={this.submit} - disabled={!this.formIsChanged()} - testID='room-info-edit-view-submit' + style={[sharedStyles.buttonContainer_inverted, styles.buttonInverted, { flex: 1 }]} + onPress={this.reset} + testID='room-info-edit-view-reset' > - <Text style={sharedStyles.button} accessibilityTraits='button'>{I18n.t('SAVE')}</Text> + <Text style={sharedStyles.button_inverted} accessibilityTraits='button'>{I18n.t('RESET')}</Text> </TouchableOpacity> - <View style={{ flexDirection: 'row' }}> - <TouchableOpacity - style={[sharedStyles.buttonContainer_inverted, styles.buttonInverted, { flex: 1 }]} - onPress={this.reset} - testID='room-info-edit-view-reset' - > - <Text style={sharedStyles.button_inverted} accessibilityTraits='button'>{I18n.t('RESET')}</Text> - </TouchableOpacity> - <TouchableOpacity - style={[ - sharedStyles.buttonContainer_inverted, - styles.buttonDanger, - !this.hasArchivePermission() && sharedStyles.opacity5, - { flex: 1, marginLeft: 10 } - ]} - onPress={this.toggleArchive} - disabled={!this.hasArchivePermission()} - testID='room-info-edit-view-archive' - > - <Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'> - { room.archived ? I18n.t('UNARCHIVE') : I18n.t('ARCHIVE') } - </Text> - </TouchableOpacity> - </View> - <View style={styles.divider} /> <TouchableOpacity style={[ sharedStyles.buttonContainer_inverted, - sharedStyles.buttonContainerLastChild, styles.buttonDanger, - !this.hasDeletePermission() && sharedStyles.opacity5 + !this.hasArchivePermission() && sharedStyles.opacity5, + { flex: 1, marginLeft: 10 } ]} - onPress={this.delete} - disabled={!this.hasDeletePermission()} - testID='room-info-edit-view-delete' + onPress={this.toggleArchive} + disabled={!this.hasArchivePermission()} + testID='room-info-edit-view-archive' > - <Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'>{I18n.t('DELETE')}</Text> + <Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'> + { room.archived ? I18n.t('UNARCHIVE') : I18n.t('ARCHIVE') } + </Text> </TouchableOpacity> </View> + <View style={styles.divider} /> + <TouchableOpacity + style={[ + sharedStyles.buttonContainer_inverted, + sharedStyles.buttonContainerLastChild, + styles.buttonDanger, + !this.hasDeletePermission() && sharedStyles.opacity5 + ]} + onPress={this.delete} + disabled={!this.hasDeletePermission()} + testID='room-info-edit-view-delete' + > + <Text style={[sharedStyles.button_inverted, styles.colorDanger]} accessibilityTraits='button'>{I18n.t('DELETE')}</Text> + </TouchableOpacity> <Loading visible={this.state.saving} /> </SafeAreaView> </ScrollView> diff --git a/app/views/RoomInfoView/index.js b/app/views/RoomInfoView/index.js index 68209a655..0047354d9 100644 --- a/app/views/RoomInfoView/index.js +++ b/app/views/RoomInfoView/index.js @@ -61,7 +61,7 @@ export default class RoomInfoView extends LoggedView { accessibilityTraits='button' testID='room-info-view-edit-button' > - <View style={styles.headerButton}> + <View style={sharedStyles.headerButton}> <MaterialIcon name='edit' size={20} /> </View> </Touch> @@ -146,17 +146,18 @@ export default class RoomInfoView extends LoggedView { ); renderRoles = () => ( - this.state.roles.length > 0 && - <View style={styles.item}> - <Text style={styles.itemLabel}>{I18n.t('Roles')}</Text> - <View style={styles.rolesContainer}> - {this.state.roles.map(role => ( - <View style={styles.roleBadge} key={role}> - <Text>{ this.props.roles[role] }</Text> - </View> - ))} + this.state.roles.length > 0 ? + <View style={styles.item}> + <Text style={styles.itemLabel}>{I18n.t('Roles')}</Text> + <View style={styles.rolesContainer}> + {this.state.roles.map(role => ( + <View style={styles.roleBadge} key={role}> + <Text>{ this.props.roles[role] }</Text> + </View> + ))} + </View> </View> - </View> + : null ) renderTimezone = (userId) => { @@ -210,12 +211,12 @@ export default class RoomInfoView extends LoggedView { {this.renderAvatar(room, roomUser)} <View style={styles.roomTitleContainer}>{ getRoomTitle(room) }</View> </View> - {!this.isDirect() && this.renderItem('description', room)} - {!this.isDirect() && this.renderItem('topic', room)} - {!this.isDirect() && this.renderItem('announcement', room)} - {this.isDirect() && this.renderRoles()} - {this.isDirect() && this.renderTimezone(roomUser._id)} - {room.broadcast && this.renderBroadcast()} + {!this.isDirect() ? this.renderItem('description', room) : null} + {!this.isDirect() ? this.renderItem('topic', room) : null} + {!this.isDirect() ? this.renderItem('announcement', room) : null} + {this.isDirect() ? this.renderRoles() : null} + {this.isDirect() ? this.renderTimezone(roomUser._id) : null} + {room.broadcast ? this.renderBroadcast() : null} </ScrollView> ); } diff --git a/app/views/RoomInfoView/styles.js b/app/views/RoomInfoView/styles.js index fe9c0af71..70c44dc17 100644 --- a/app/views/RoomInfoView/styles.js +++ b/app/views/RoomInfoView/styles.js @@ -7,13 +7,6 @@ export default StyleSheet.create({ backgroundColor: '#ffffff', padding: 10 }, - headerButton: { - backgroundColor: 'transparent', - height: 44, - width: 44, - alignItems: 'center', - justifyContent: 'center' - }, item: { padding: 10, // borderColor: '#EBEDF1', diff --git a/app/views/RoomMembersView/index.js b/app/views/RoomMembersView/index.js index 68fc2d0cf..dcadebf56 100644 --- a/app/views/RoomMembersView/index.js +++ b/app/views/RoomMembersView/index.js @@ -1,13 +1,13 @@ import React from 'react'; 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 ActionSheet from 'react-native-actionsheet'; import LoggedView from '../View'; import styles from './styles'; +import sharedStyles from '../Styles'; import RoomItem from '../../presentation/RoomItem'; -import Touch from '../../utils/touch'; import scrollPersistTaps from '../../utils/scrollPersistTaps'; import RocketChat from '../../lib/rocketchat'; import { goRoom } from '../../containers/routes/NavigationService'; @@ -33,19 +33,15 @@ export default class MentionedMessagesView extends LoggedView { } return { headerRight: ( - <Touch + <TouchableOpacity onPress={params.onPressToogleStatus} - underlayColor='#ffffff' - activeOpacity={0.5} accessibilityLabel={label} accessibilityTraits='button' - style={styles.headerButtonTouchable} + style={[sharedStyles.headerButton, styles.headerButton]} testID='room-members-view-toggle-status' > - <View style={styles.headerButton}> - <Text style={styles.headerButtonText}>{label}</Text> - </View> - </Touch> + <Text>{label}</Text> + </TouchableOpacity> ) }; }; diff --git a/app/views/RoomMembersView/styles.js b/app/views/RoomMembersView/styles.js index 12faa2c29..07e5c1e5a 100644 --- a/app/views/RoomMembersView/styles.js +++ b/app/views/RoomMembersView/styles.js @@ -31,18 +31,6 @@ export default StyleSheet.create({ fontSize: 16, color: '#444' }, - headerButtonTouchable: { - borderRadius: 4 - }, - headerButton: { - padding: 6, - backgroundColor: 'transparent', - alignItems: 'center', - justifyContent: 'center' - }, - headerButtonText: { - color: '#292E35' - }, searchBoxView: { backgroundColor: '#eee' }, @@ -53,5 +41,9 @@ export default StyleSheet.create({ padding: 5, paddingLeft: 10, color: '#aaa' + }, + headerButton: { + marginRight: 9, + alignItems: 'flex-end' } }); diff --git a/app/views/RoomView/Header/index.js b/app/views/RoomView/Header/index.js index 275150aad..2cf249c17 100644 --- a/app/views/RoomView/Header/index.js +++ b/app/views/RoomView/Header/index.js @@ -14,6 +14,7 @@ import { closeRoom } from '../../../actions/room'; import log from '../../../utils/log'; import RoomTypeIcon from '../../../containers/RoomTypeIcon'; import I18n from '../../../i18n'; +import sharedStyles from '../../Styles'; const title = (offline, connecting, authenticating, logged) => { if (offline) { @@ -159,7 +160,7 @@ export default class RoomHeaderView extends React.PureComponent { </Text> </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> </TouchableOpacity> @@ -169,7 +170,7 @@ export default class RoomHeaderView extends React.PureComponent { renderRight = () => ( <View style={styles.right}> <TouchableOpacity - style={styles.headerButton} + style={sharedStyles.headerButton} onPress={() => { try { RocketChat.toggleFavorite(this.state.room.rid, this.state.room.f); @@ -189,7 +190,7 @@ export default class RoomHeaderView extends React.PureComponent { /> </TouchableOpacity> <TouchableOpacity - style={styles.headerButton} + style={sharedStyles.headerButton} onPress={() => this.props.navigation.navigate({ key: 'RoomActions', routeName: 'RoomActions', params: { rid: this.state.room.rid } })} accessibilityLabel={I18n.t('Room_actions')} accessibilityTraits='button' diff --git a/app/views/RoomView/Header/styles.js b/app/views/RoomView/Header/styles.js index 56cbd186c..1be2af4f5 100644 --- a/app/views/RoomView/Header/styles.js +++ b/app/views/RoomView/Header/styles.js @@ -40,13 +40,6 @@ export default StyleSheet.create({ right: { flexDirection: 'row' }, - headerButton: { - backgroundColor: 'transparent', - height: 44, - width: 40, - alignItems: 'center', - justifyContent: 'center' - }, avatar: { marginRight: 5 } diff --git a/app/views/RoomView/ListView.js b/app/views/RoomView/ListView.js index a9036a6ef..95443cfca 100644 --- a/app/views/RoomView/ListView.js +++ b/app/views/RoomView/ListView.js @@ -113,8 +113,8 @@ export class ListView extends OldList2 { // const { renderSectionHeader } = this.props; - const header = this.props.renderHeader && this.props.renderHeader(); - const footer = this.props.renderFooter && this.props.renderFooter(); + const header = this.props.renderHeader ? this.props.renderHeader() : null; + const footer = this.props.renderFooter ? this.props.renderFooter() : null; // let totalIndex = header ? 1 : 0; const { data } = this.props; diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 28cfadb00..db86edd13 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -95,8 +95,12 @@ export default class RoomView extends LoggedView { } requestAnimationFrame(async() => { - const result = await RocketChat.loadMessagesForRoom({ rid: this.rid, t: this.state.room.t, latest: lastRowData.ts }); - this.setState({ end: result < 20 }); + try { + const result = await RocketChat.loadMessagesForRoom({ rid: this.rid, t: this.state.room.t, latest: lastRowData.ts }); + this.setState({ end: result < 20 }); + } catch (e) { + log('RoomView.onEndReached', e); + } }); } diff --git a/app/views/RoomsListView/Header/index.js b/app/views/RoomsListView/Header/index.js index a225b7d70..f9272ac2b 100644 --- a/app/views/RoomsListView/Header/index.js +++ b/app/views/RoomsListView/Header/index.js @@ -13,6 +13,7 @@ import RocketChat from '../../../lib/rocketchat'; import { STATUS_COLORS } from '../../../constants/colors'; import { setSearch } from '../../../actions/rooms'; import styles from './styles'; +import sharedStyles from '../../Styles'; import log from '../../../utils/log'; import I18n from '../../../i18n'; @@ -130,7 +131,7 @@ export default class RoomsListHeaderView extends React.PureComponent { testID='rooms-list-view-sidebar' > <TouchableOpacity - style={styles.headerButton} + style={sharedStyles.headerButton} onPress={() => this.props.navigation.openDrawer()} > <FastImage @@ -174,7 +175,7 @@ export default class RoomsListHeaderView extends React.PureComponent { </Avatar> <View style={styles.rows}> <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> </TouchableOpacity> ); @@ -189,7 +190,7 @@ export default class RoomsListHeaderView extends React.PureComponent { <View style={styles.right}> {Platform.OS === 'android' ? <TouchableOpacity - style={styles.headerButton} + style={sharedStyles.headerButton} onPress={() => this.onPressSearchButton()} accessibilityLabel={I18n.t('Search')} accessibilityTraits='button' @@ -203,7 +204,7 @@ export default class RoomsListHeaderView extends React.PureComponent { </TouchableOpacity> : null} {Platform.OS === 'ios' ? <TouchableOpacity - style={styles.headerButton} + style={sharedStyles.headerButton} onPress={() => this.createChannel()} accessibilityLabel={I18n.t('Create_Channel')} accessibilityTraits='button' diff --git a/app/views/RoomsListView/Header/styles.js b/app/views/RoomsListView/Header/styles.js index 1a4e2f587..282bbe58d 100644 --- a/app/views/RoomsListView/Header/styles.js +++ b/app/views/RoomsListView/Header/styles.js @@ -55,13 +55,6 @@ export default StyleSheet.create({ borderBottomColor: 'rgba(0, 0, 0, .3)', paddingHorizontal: 20 }, - headerButton: { - backgroundColor: 'transparent', - height: 44, - width: 44, - alignItems: 'center', - justifyContent: 'center' - }, user_status: { position: 'absolute', bottom: -2, diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index 929e0cfb9..444ebee3b 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -223,6 +223,6 @@ export default class RoomsListView extends LoggedView { render = () => ( <View style={styles.container} testID='rooms-list-view'> {this.renderList()} - {Platform.OS === 'android' && this.renderCreateButtons()} + {Platform.OS === 'android' ? this.renderCreateButtons() : null} </View>) } diff --git a/app/views/SearchMessagesView/index.js b/app/views/SearchMessagesView/index.js index 4aa45da26..db6bc7162 100644 --- a/app/views/SearchMessagesView/index.js +++ b/app/views/SearchMessagesView/index.js @@ -129,8 +129,8 @@ export default class SearchMessagesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={searching && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={searching ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} {...scrollPersistTaps} /> </View> diff --git a/app/views/SettingsView/index.js b/app/views/SettingsView/index.js index c25511c71..8c1bac4dd 100644 --- a/app/views/SettingsView/index.js +++ b/app/views/SettingsView/index.js @@ -1,18 +1,134 @@ 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 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 { + static propTypes = { + user: PropTypes.object, + setUser: PropTypes.func + }; + constructor(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() { + const { language, languages, placeholder } = this.state; return ( - <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}> - <Text>SettingsView</Text> - </View> + <KeyboardView + 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> + <Loading visible={this.state.saving} /> + </SafeAreaView> + </ScrollView> + </KeyboardView> ); } } diff --git a/app/views/SnippetedMessagesView/index.js b/app/views/SnippetedMessagesView/index.js index f1b240023..7b0f426dd 100644 --- a/app/views/SnippetedMessagesView/index.js +++ b/app/views/SnippetedMessagesView/index.js @@ -109,8 +109,8 @@ export default class SnippetedMessagesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={loading && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={loading ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} /> ] ); diff --git a/app/views/StarredMessagesView/index.js b/app/views/StarredMessagesView/index.js index 6feed3c3b..ce468bc89 100644 --- a/app/views/StarredMessagesView/index.js +++ b/app/views/StarredMessagesView/index.js @@ -133,8 +133,8 @@ export default class StarredMessagesView extends LoggedView { style={styles.list} keyExtractor={item => item._id} onEndReached={this.moreData} - ListHeaderComponent={loading && <RCActivityIndicator />} - ListFooterComponent={loadingMore && <RCActivityIndicator />} + ListHeaderComponent={loading ? <RCActivityIndicator /> : null} + ListFooterComponent={loadingMore ? <RCActivityIndicator /> : null} />, <ActionSheet key='starred-messages-view-action-sheet' diff --git a/app/views/Styles.js b/app/views/Styles.js index 2a0d4d040..9570aa898 100644 --- a/app/views/Styles.js +++ b/app/views/Styles.js @@ -195,5 +195,12 @@ export default StyleSheet.create({ width: 50, height: 50, marginVertical: 25 + }, + headerButton: { + backgroundColor: 'transparent', + height: 44, + width: 44, + alignItems: 'center', + justifyContent: 'center' } }); diff --git a/e2e/11-broadcast.spec.js b/e2e/11-broadcast.spec.js index 675b81a24..4346b7f26 100644 --- a/e2e/11-broadcast.spec.js +++ b/e2e/11-broadcast.spec.js @@ -2,7 +2,7 @@ const { device, expect, element, by, waitFor } = require('detox'); const { takeScreenshot } = require('./helpers/screenshot'); -const { logout, navigateToLogin } = require('./helpers/app'); +const { logout, navigateToLogin, login } = require('./helpers/app'); const data = require('./data'); describe('Broadcast room', () => { @@ -99,4 +99,13 @@ describe('Broadcast room', () => { afterEach(async() => { 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(); + }) }); diff --git a/e2e/12-profile.spec.js b/e2e/12-profile.spec.js new file mode 100644 index 000000000..ecf2ff107 --- /dev/null +++ b/e2e/12-profile.spec.js @@ -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(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index e0d2ec7de..1458aa307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10764,6 +10764,11 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.5.tgz", "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": { "version": "3.0.2", "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", "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": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -14980,6 +14990,26 @@ "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": { "version": "1.0.0", "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" } }, + "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/react-native-push-notification/-/react-native-push-notification-3.0.2.tgz", diff --git a/package.json b/package.json index 1bf7ec01a..c2faafedd 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "deep-equal": "^1.0.1", "ejson": "^2.1.2", "js-base64": "^2.4.5", + "js-sha256": "^0.9.0", "lodash": "^4.17.10", "markdown-it-flowdock": "^0.3.7", "moment": "^2.22.2", @@ -44,6 +45,7 @@ "react-native-action-button": "^2.8.3", "react-native-actionsheet": "^2.4.2", "react-native-audio": "^4.1.3", + "react-native-dialog": "^4.0.0", "react-native-fabric": "^0.5.1", "react-native-fast-image": "^4.0.14", "react-native-fetch-blob": "^0.10.8", @@ -56,6 +58,7 @@ "react-native-meteor": "^1.3.0", "react-native-modal": "^6.1.0", "react-native-optimized-flatlist": "^1.0.4", + "react-native-picker-select": "^3.1.1", "react-native-push-notification": "^3.0.1", "react-native-responsive-ui": "^1.1.1", "react-native-safari-view": "^2.1.0", -- GitLab