diff --git a/.circleci/config.yml b/.circleci/config.yml index 14ba8f40b..bcb01f2a8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ defaults: &defaults macos: &macos macos: - xcode: "11.2.1" + xcode: "11.5.0" bash-env: &bash-env BASH_ENV: "~/.nvm/nvm.sh" @@ -33,14 +33,12 @@ save-npm-cache-mac: &save-npm-cache-mac - ./node_modules install-node: &install-node - name: Install Node 10 + name: Install Node command: | - curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.6/install.sh | bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash source ~/.nvm/nvm.sh - # https://github.com/creationix/nvm/issues/1394 - set +e - nvm install 10 - echo 'export PATH="/home/circleci/.nvm/versions/node/v10.20.1/bin:$PATH"' >> ~/.bash_profile + INSTALLED_NODE=`nvm which current` + echo "export PATH=\"${INSTALLED_NODE%%/node}:\$PATH\"" >> ~/.bash_profile source ~/.bash_profile restore-gems-cache: &restore-gems-cache diff --git a/.gitignore b/.gitignore index 6e797d850..fee7bf865 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ buck-out/ coverage .vscode/ +e2e/docker/rc_test_env/docker-compose.yml +e2e/docker/data/db \ No newline at end of file diff --git a/app/containers/ActionSheet/styles.js b/app/containers/ActionSheet/styles.js index d87c35f12..76078233b 100644 --- a/app/containers/ActionSheet/styles.js +++ b/app/containers/ActionSheet/styles.js @@ -29,7 +29,8 @@ export default StyleSheet.create({ }, handle: { justifyContent: 'center', - alignItems: 'center' + alignItems: 'center', + paddingBottom: 8 }, handleIndicator: { width: 40, diff --git a/app/containers/LoginServices.js b/app/containers/LoginServices.js index b56929033..11ffc6d97 100644 --- a/app/containers/LoginServices.js +++ b/app/containers/LoginServices.js @@ -5,29 +5,32 @@ import { import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Base64 } from 'js-base64'; +import * as AppleAuthentication from 'expo-apple-authentication'; import { withTheme } from '../theme'; import sharedStyles from '../views/Styles'; import { themes } from '../constants/colors'; -import { loginRequest as loginRequestAction } from '../actions/login'; import Button from './Button'; import OrSeparator from './OrSeparator'; import Touch from '../utils/touch'; import I18n from '../i18n'; import random from '../utils/random'; +import RocketChat from '../lib/rocketchat'; +const BUTTON_HEIGHT = 48; const SERVICE_HEIGHT = 58; +const BORDER_RADIUS = 2; const SERVICES_COLLAPSED_HEIGHT = 174; const styles = StyleSheet.create({ serviceButton: { - borderRadius: 2, + borderRadius: BORDER_RADIUS, marginBottom: 10 }, serviceButtonContainer: { - borderRadius: 2, + borderRadius: BORDER_RADIUS, width: '100%', - height: 48, + height: BUTTON_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -187,6 +190,21 @@ class LoginServices extends React.PureComponent { this.openOAuth({ url, ssoToken, authType: 'cas' }); } + onPressAppleLogin = async() => { + try { + const { fullName, email, identityToken } = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL + ] + }); + + await RocketChat.loginOAuthOrSso({ fullName, email, identityToken }); + } catch { + // Do nothing + } + } + getOAuthState = () => { const credentialToken = random(43); return Base64.encodeURI(JSON.stringify({ loginStyle: 'popup', credentialToken, isCordova: true })); @@ -262,6 +280,7 @@ class LoginServices extends React.PureComponent { } renderItem = (service) => { + const { CAS_enabled, theme } = this.props; let { name } = service; name = name === 'meteor-developer' ? 'meteor' : name; const icon = `icon_${ name }`; @@ -285,11 +304,27 @@ class LoginServices extends React.PureComponent { onPress = () => this.onPressCas(); break; } + case 'apple': { + onPress = () => this.onPressAppleLogin(); + break; + } default: break; } + + if (name === 'apple') { + return ( + + ); + } + name = name.charAt(0).toUpperCase() + name.slice(1); - const { CAS_enabled, theme } = this.props; let buttonText; if (isSaml || (service.service === 'cas' && CAS_enabled)) { buttonText = {name}; @@ -356,8 +391,4 @@ const mapStateToProps = state => ({ services: state.login.services }); -const mapDispatchToProps = dispatch => ({ - loginRequest: params => dispatch(loginRequestAction(params)) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(withTheme(LoginServices)); +export default connect(mapStateToProps)(withTheme(LoginServices)); diff --git a/app/containers/MessageActions/Header.js b/app/containers/MessageActions/Header.js index 5db34acec..7847069e1 100644 --- a/app/containers/MessageActions/Header.js +++ b/app/containers/MessageActions/Header.js @@ -14,17 +14,20 @@ import { Button } from '../ActionSheet'; import { useDimensions } from '../../dimensions'; export const HEADER_HEIGHT = 36; +const ITEM_SIZE = 36; +const CONTAINER_MARGIN = 8; +const ITEM_MARGIN = 8; const styles = StyleSheet.create({ container: { alignItems: 'center', - marginHorizontal: 8 + marginHorizontal: CONTAINER_MARGIN }, headerItem: { - height: 36, - width: 36, - borderRadius: 20, - marginHorizontal: 8, + height: ITEM_SIZE, + width: ITEM_SIZE, + borderRadius: ITEM_SIZE / 2, + marginHorizontal: ITEM_MARGIN, justifyContent: 'center', alignItems: 'center' }, @@ -84,7 +87,7 @@ HeaderFooter.propTypes = { }; const Header = React.memo(({ - handleReaction, server, message, theme + handleReaction, server, message, isMasterDetail, theme }) => { const [items, setItems] = useState([]); const { width, height } = useDimensions(); @@ -96,8 +99,8 @@ const Header = React.memo(({ let freqEmojis = await freqEmojiCollection.query().fetch(); const isLandscape = width > height; - const size = isLandscape ? width / 2 : width; - const quantity = (size / 50) - 1; + const size = (isLandscape || isMasterDetail ? width / 2 : width) - (CONTAINER_MARGIN * 2); + const quantity = (size / (ITEM_SIZE + (ITEM_MARGIN * 2))) - 1; freqEmojis = freqEmojis.concat(DEFAULT_EMOJIS).slice(0, quantity); setItems(freqEmojis); @@ -135,6 +138,7 @@ Header.propTypes = { handleReaction: PropTypes.func, server: PropTypes.string, message: PropTypes.object, + isMasterDetail: PropTypes.bool, theme: PropTypes.string }; export default withTheme(Header); diff --git a/app/containers/MessageActions/index.js b/app/containers/MessageActions/index.js index 4ba286db9..7846e1d70 100644 --- a/app/containers/MessageActions/index.js +++ b/app/containers/MessageActions/index.js @@ -32,7 +32,8 @@ const MessageActions = React.memo(forwardRef(({ Message_AllowEditing_BlockEditInMinutes, Message_AllowPinning, Message_AllowStarring, - Message_Read_Receipt_Store_Users + Message_Read_Receipt_Store_Users, + isMasterDetail }, ref) => { let permissions = {}; const { showActionSheet, hideActionSheet } = useActionSheet(); @@ -116,7 +117,12 @@ const MessageActions = React.memo(forwardRef(({ const handleEdit = message => editInit(message); const handleCreateDiscussion = (message) => { - Navigation.navigate('CreateDiscussionView', { message, channel: room }); + const params = { message, channel: room, showCloseModal: true }; + if (isMasterDetail) { + Navigation.navigate('ModalStackNavigator', { screen: 'CreateDiscussionView', params }); + } else { + Navigation.navigate('NewMessageStackNavigator', { screen: 'CreateDiscussionView', params }); + } }; const handleUnread = async(message) => { @@ -377,6 +383,7 @@ const MessageActions = React.memo(forwardRef(({
) : null) @@ -412,7 +419,8 @@ const mapStateToProps = state => ({ Message_AllowEditing_BlockEditInMinutes: state.settings.Message_AllowEditing_BlockEditInMinutes, Message_AllowPinning: state.settings.Message_AllowPinning, Message_AllowStarring: state.settings.Message_AllowStarring, - Message_Read_Receipt_Store_Users: state.settings.Message_Read_Receipt_Store_Users + Message_Read_Receipt_Store_Users: state.settings.Message_Read_Receipt_Store_Users, + isMasterDetail: state.app.isMasterDetail }); export default connect(mapStateToProps, null, null, { forwardRef: true })(MessageActions); diff --git a/app/containers/MessageBox/EmojiKeyboard.js b/app/containers/MessageBox/EmojiKeyboard.js index f8bc13019..8d552509c 100644 --- a/app/containers/MessageBox/EmojiKeyboard.js +++ b/app/containers/MessageBox/EmojiKeyboard.js @@ -17,7 +17,7 @@ export default class EmojiKeyboard extends React.PureComponent { constructor(props) { super(props); const state = store.getState(); - this.baseUrl = state.server.server; + this.baseUrl = state.share.server || state.server.server; } onEmojiSelected = (emoji) => { diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index 43e23c8dd..813d7d64c 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -541,12 +541,14 @@ class MessageBox extends Component { setCommandPreview = async(command, name, params) => { const { rid } = this.props; try { - const { preview } = await RocketChat.getCommandPreview(name, rid, params); - this.setState({ commandPreview: preview.items, showCommandPreview: true, command }); + const { success, preview } = await RocketChat.getCommandPreview(name, rid, params); + if (success) { + return this.setState({ commandPreview: preview?.items, showCommandPreview: true, command }); + } } catch (e) { - this.setState({ commandPreview: [], showCommandPreview: true, command: {} }); log(e); } + this.setState({ commandPreview: [], showCommandPreview: true, command: {} }); } setInput = (text) => { @@ -906,7 +908,7 @@ class MessageBox extends Component { return ( <> {commandsPreviewAndMentions} - + {replyPreview} s._id); reduxStore.dispatch(addSettings(this.parseSettings(filteredSettings))); - InteractionManager.runAfterInteractions(async() => { - // filter server info - const serverInfo = filteredSettings.filter(i1 => serverInfoKeys.includes(i1._id)); - const iconSetting = data.find(item => item._id === 'Assets_favicon_512'); - await serverInfoUpdate(serverInfo, iconSetting); - await db.action(async() => { - const settingsCollection = db.collections.get('settings'); - const allSettingsRecords = await settingsCollection - .query(Q.where('id', Q.oneOf(filteredSettingsIds))) - .fetch(); + // filter server info + const serverInfo = filteredSettings.filter(i1 => serverInfoKeys.includes(i1._id)); + const iconSetting = data.find(item => item._id === 'Assets_favicon_512'); + await serverInfoUpdate(serverInfo, iconSetting); - // filter settings - let settingsToCreate = filteredSettings.filter(i1 => !allSettingsRecords.find(i2 => i1._id === i2.id)); - let settingsToUpdate = allSettingsRecords.filter(i1 => filteredSettings.find(i2 => i1.id === i2._id)); + await db.action(async() => { + const settingsCollection = db.collections.get('settings'); + const allSettingsRecords = await settingsCollection + .query(Q.where('id', Q.oneOf(filteredSettingsIds))) + .fetch(); - // Create - settingsToCreate = settingsToCreate.map(setting => settingsCollection.prepareCreate(protectedFunction((s) => { - s._raw = sanitizedRaw({ id: setting._id }, settingsCollection.schema); - Object.assign(s, setting); - }))); + // filter settings + let settingsToCreate = filteredSettings.filter(i1 => !allSettingsRecords.find(i2 => i1._id === i2.id)); + let settingsToUpdate = allSettingsRecords.filter(i1 => filteredSettings.find(i2 => i1.id === i2._id)); - // Update - settingsToUpdate = settingsToUpdate.map((setting) => { - const newSetting = filteredSettings.find(s => s._id === setting.id); - return setting.prepareUpdate(protectedFunction((s) => { - Object.assign(s, newSetting); - })); - }); + // Create + settingsToCreate = settingsToCreate.map(setting => settingsCollection.prepareCreate(protectedFunction((s) => { + s._raw = sanitizedRaw({ id: setting._id }, settingsCollection.schema); + Object.assign(s, setting); + }))); - const allRecords = [ - ...settingsToCreate, - ...settingsToUpdate - ]; - - try { - await db.batch(...allRecords); - } catch (e) { - log(e); - } - return allRecords.length; + // Update + settingsToUpdate = settingsToUpdate.map((setting) => { + const newSetting = filteredSettings.find(s => s._id === setting.id); + return setting.prepareUpdate(protectedFunction((s) => { + Object.assign(s, newSetting); + })); }); + + const allRecords = [ + ...settingsToCreate, + ...settingsToUpdate + ]; + + try { + await db.batch(...allRecords); + } catch (e) { + log(e); + } + return allRecords.length; }); } catch (e) { log(e); diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 06ff12eda..cfa959919 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -288,6 +288,8 @@ const RocketChat = { const serversDB = database.servers; reduxStore.dispatch(shareSelectServer(server)); + RocketChat.setCustomEmojis(); + // set User info try { const userId = await RNUserDefaults.get(`${ RocketChat.TOKEN_KEY }-${ server }`); @@ -320,7 +322,7 @@ const RocketChat = { updateJitsiTimeout(roomId) { // RC 0.74.0 - return this.post('jitsi.updateTimeout', { roomId }); + return this.post('video-conference/jitsi.update-timeout', { roomId }); }, register(credentials) { @@ -1075,6 +1077,10 @@ const RocketChat = { return 'cas'; } + if (authName === 'apple' && isIOS) { + return 'apple'; + } + // TODO: remove this after other oauth providers are implemented. e.g. Drupal, github_enterprise const availableOAuth = ['facebook', 'github', 'gitlab', 'google', 'linkedin', 'meteor-developer', 'twitter', 'wordpress']; return availableOAuth.includes(authName) ? 'oauth' : 'not_supported'; diff --git a/app/presentation/ImageViewer/ImageViewer.android.js b/app/presentation/ImageViewer/ImageViewer.android.js index 9c3a29049..2cd8578bb 100644 --- a/app/presentation/ImageViewer/ImageViewer.android.js +++ b/app/presentation/ImageViewer/ImageViewer.android.js @@ -260,6 +260,21 @@ function bouncy( const WIDTH = 300; const HEIGHT = 300; +class Image extends React.PureComponent { + static propTypes = { + imageComponentType: PropTypes.string + } + + render() { + const { imageComponentType } = this.props; + + const Component = ImageComponent(imageComponentType); + + return ; + } +} +const AnimatedImage = Animated.createAnimatedComponent(Image); + // it was picked from https://github.com/software-mansion/react-native-reanimated/tree/master/Example/imageViewer // and changed to use FastImage animated component export class ImageViewer extends React.Component { @@ -386,12 +401,9 @@ export class ImageViewer extends React.Component { render() { const { - uri, width, height, theme, imageComponentType, ...props + uri, width, height, imageComponentType, theme, ...props } = this.props; - const Component = ImageComponent(imageComponentType); - const AnimatedFastImage = Animated.createAnimatedComponent(Component); - // The below two animated values makes it so that scale appears to be done // from the top left corner of the image view instead of its center. This // is required for the "scale focal point" math to work correctly @@ -416,7 +428,7 @@ export class ImageViewer extends React.Component { onGestureEvent={this._onPanEvent} onHandlerStateChange={this._onPanEvent} > - { const translateX = multiply( current.progress.interpolate({ @@ -65,13 +64,12 @@ const forStackAndroid = ({ inverted ); - const opacity = conditional( - closing, - current.progress, + const opacity = multiply( current.progress.interpolate({ inputRange: [0, 1], outputRange: [0, 1] - }) + }), + inverted ); return { diff --git a/app/utils/navigation/index.js b/app/utils/navigation/index.js index e3492df60..6f4faf6c1 100644 --- a/app/utils/navigation/index.js +++ b/app/utils/navigation/index.js @@ -46,9 +46,9 @@ export const navigationTheme = (theme) => { // Gets the current screen from navigation state export const getActiveRoute = (state) => { - const route = state.routes[state.index]; + const route = state?.routes[state?.index]; - if (route.state) { + if (route?.state) { // Dive into nested navigators return getActiveRoute(route.state); } @@ -56,4 +56,4 @@ export const getActiveRoute = (state) => { return route; }; -export const getActiveRouteName = state => getActiveRoute(state).name; +export const getActiveRouteName = state => getActiveRoute(state)?.name; diff --git a/app/views/AttachmentView.js b/app/views/AttachmentView.js index a4b110c72..6f43823da 100644 --- a/app/views/AttachmentView.js +++ b/app/views/AttachmentView.js @@ -70,10 +70,15 @@ class AttachmentView extends React.Component { setHeader = () => { const { route, navigation, theme } = this.props; const attachment = route.params?.attachment; - const { title } = attachment; + let { title } = attachment; + try { + title = decodeURI(title); + } catch { + // Do nothing + } const options = { + title, headerLeft: () => , - title: decodeURI(title), headerRight: () => , headerBackground: () => , headerTintColor: themes[theme].previewTintColor, diff --git a/app/views/NotificationPreferencesView/index.js b/app/views/NotificationPreferencesView/index.js index 1c0f43c8c..31a0fc679 100644 --- a/app/views/NotificationPreferencesView/index.js +++ b/app/views/NotificationPreferencesView/index.js @@ -16,6 +16,7 @@ import RocketChat from '../../lib/rocketchat'; import { withTheme } from '../../theme'; import protectedFunction from '../../lib/methods/helpers/protectedFunction'; import SafeAreaView from '../../containers/SafeAreaView'; +import log from '../../utils/log'; const SectionTitle = React.memo(({ title, theme }) => ( { - await room.update(protectedFunction((r) => { - r[key] = value; - })); - }); - try { - const result = await RocketChat.saveNotificationSettings(this.rid, params); - if (result.success) { - return; - } - } catch { - // do nothing - } + await db.action(async() => { + await room.update(protectedFunction((r) => { + r[key] = value; + })); + }); - await db.action(async() => { - await room.update(protectedFunction((r) => { - r[key] = room[key]; - })); - }); + try { + const result = await RocketChat.saveNotificationSettings(this.rid, params); + if (result.success) { + return; + } + } catch { + // do nothing + } + + await db.action(async() => { + await room.update(protectedFunction((r) => { + r[key] = room[key]; + })); + }); + } catch (e) { + log(e); + } } onValueChangeSwitch = (key, value) => this.saveNotificationSettings(key, value, { [key]: value ? '1' : '0' }); diff --git a/app/views/RegisterView.js b/app/views/RegisterView.js index 1fe127416..1e0ce2654 100644 --- a/app/views/RegisterView.js +++ b/app/views/RegisterView.js @@ -145,10 +145,12 @@ class RegisterView extends React.Component { await loginRequest({ user: email, password }); } } catch (e) { - if (e.data && e.data.errorType === 'username-invalid') { + if (e.data?.errorType === 'username-invalid') { return loginRequest({ user: email, password }); } - showErrorAlert(e.data.error, I18n.t('Oops')); + if (e.data?.error) { + showErrorAlert(e.data.error, I18n.t('Oops')); + } } this.setState({ saving: false }); } diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index a4a7549f5..0b6afd18f 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -769,7 +769,7 @@ class RoomView extends React.Component { if (handleCommandScroll(event)) { const offset = input === 'UIKeyInputUpArrow' ? 100 : -100; this.offset += offset; - this.flatList.scrollToOffset({ offset: this.offset }); + this.flatList?.scrollToOffset({ offset: this.offset }); } else if (handleCommandRoomActions(event)) { this.goRoomActionsView(); } else if (handleCommandSearchMessages(event)) { diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index 9bf50c088..232ea3228 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -512,12 +512,7 @@ class RoomsListView extends React.Component { this.setHeader(); closeSearchHeader(); setTimeout(() => { - const offset = 0; - if (this.scroll.scrollTo) { - this.scroll.scrollTo({ x: 0, y: offset, animated: true }); - } else if (this.scroll.scrollToOffset) { - this.scroll.scrollToOffset({ offset }); - } + this.scrollToTop(); }, 200); }); }; @@ -546,9 +541,7 @@ class RoomsListView extends React.Component { search: result, searching: true }); - if (this.scroll && this.scroll.scrollTo) { - this.scroll.scrollTo({ x: 0, y: 0, animated: true }); - } + this.scrollToTop(); }, 300); getRoomTitle = item => RocketChat.getRoomTitle(item) @@ -569,15 +562,16 @@ class RoomsListView extends React.Component { this.goRoom({ item, isMasterDetail }); }; + scrollToTop = () => { + if (this.scroll?.scrollToOffset) { + this.scroll.scrollToOffset({ offset: 0 }); + } + } + toggleSort = () => { const { toggleSortDropdown } = this.props; - const offset = 0; - if (this.scroll.scrollTo) { - this.scroll.scrollTo({ x: 0, y: offset, animated: true }); - } else if (this.scroll.scrollToOffset) { - this.scroll.scrollToOffset({ offset }); - } + this.scrollToTop(); setTimeout(() => { toggleSortDropdown(); }, 100); diff --git a/app/views/ShareView/Preview.js b/app/views/ShareView/Preview.js index fae679535..2aaff22d8 100644 --- a/app/views/ShareView/Preview.js +++ b/app/views/ShareView/Preview.js @@ -10,12 +10,13 @@ import { ImageViewer, types } from '../../presentation/ImageViewer'; import { themes } from '../../constants/colors'; import { useDimensions, useOrientation } from '../../dimensions'; import { getHeaderHeight } from '../../containers/Header'; -import { isIOS } from '../../utils/deviceInfo'; import { THUMBS_HEIGHT } from './constants'; import sharedStyles from '../Styles'; import { allowPreview } from './utils'; import I18n from '../../i18n'; +const MESSAGEBOX_HEIGHT = 56; + const styles = StyleSheet.create({ fileContainer: { alignItems: 'center', @@ -58,23 +59,24 @@ const Preview = React.memo(({ const { isLandscape } = useOrientation(); const insets = useSafeAreaInsets(); const headerHeight = getHeaderHeight(isLandscape); - const messageboxHeight = isIOS ? 56 : 0; const thumbsHeight = (length > 1) ? THUMBS_HEIGHT : 0; - const calculatedHeight = height - insets.top - insets.bottom - messageboxHeight - thumbsHeight - headerHeight; + const calculatedHeight = height - insets.top - insets.bottom - MESSAGEBOX_HEIGHT - thumbsHeight - headerHeight; if (item?.canUpload) { if (type?.match(/video/)) { return ( -