diff --git a/app/constants/view.js b/app/constants/view.js index f2e5c4a1f..d72516223 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -95,8 +95,16 @@ const ViewTypes = keyMirror({ LANDSCAPE: null, }); +const RequiredServer = { + FULL_VERSION: 5.19, + MAJOR_VERSION: 5, + MIN_VERSION: 19, + PATCH_VERSION: 0, +}; + export default { ...ViewTypes, + RequiredServer, POST_VISIBILITY_CHUNK_SIZE: 15, FEATURE_TOGGLE_PREFIX: 'feature_enabled_', EMBED_PREVIEW: 'embed_preview', diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 600f547fa..19f68da4b 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -5,14 +5,16 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; import { + Alert, Keyboard, + Linking, StyleSheet, } from 'react-native'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import {showModal, showModalOverCurrentContext} from '@actions/navigation'; import LocalConfig from '@assets/config'; -import {NavigationTypes} from '@constants'; +import {NavigationTypes, ViewTypes} from '@constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import EphemeralStore from '@store/ephemeral_store'; import {preventDoubleTap} from '@utils/tap'; @@ -37,6 +39,7 @@ export default class ChannelBase extends PureComponent { currentChannelId: PropTypes.string, currentTeamId: PropTypes.string, disableTermsModal: PropTypes.bool, + isSupportedServer: PropTypes.bool, teamName: PropTypes.string, theme: PropTypes.object.isRequired, showTermsOfService: PropTypes.bool, @@ -67,31 +70,43 @@ export default class ChannelBase extends PureComponent { } componentDidMount() { + const { + actions, + currentChannelId, + currentTeamId, + disableTermsModal, + isSupportedServer, + showTermsOfService, + skipMetrics, + } = this.props; EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.on('leave_team', this.handleLeaveTeam); - if (this.props.currentTeamId) { - this.loadChannels(this.props.currentTeamId); + if (currentTeamId) { + this.loadChannels(currentTeamId); } else { - this.props.actions.selectDefaultTeam(); + actions.selectDefaultTeam(); } - if (this.props.currentChannelId) { - PushNotifications.clearChannelNotifications(this.props.currentChannelId); + if (currentChannelId) { + PushNotifications.clearChannelNotifications(currentChannelId); requestAnimationFrame(() => { - this.props.actions.getChannelStats(this.props.currentChannelId); + actions.getChannelStats(currentChannelId); }); } - if (tracker.initialLoad && !this.props.skipMetrics) { - this.props.actions.recordLoadTime('Start time', 'initialLoad'); + if (tracker.initialLoad && !skipMetrics) { + actions.recordLoadTime('Start time', 'initialLoad'); } - if (this.props.showTermsOfService && !this.props.disableTermsModal) { + if (showTermsOfService && !disableTermsModal) { this.showTermsOfServiceModal(); + } else if (!isSupportedServer) { + // Only display the Alert if the TOS does not need to show first + this.showUnsupportedServer(); } - if (!this.props.skipMetrics) { + if (!skipMetrics) { telemetry.end(['start:channel_screen']); } } @@ -140,31 +155,6 @@ export default class ChannelBase extends PureComponent { } }; - showTermsOfServiceModal = async () => { - const {intl} = this.context; - const {theme} = this.props; - const screen = 'TermsOfService'; - const title = intl.formatMessage({id: 'mobile.tos_link', defaultMessage: 'Terms of Service'}); - MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((closeButton) => { - const passProps = {closeButton}; - const options = { - layout: { - componentBackgroundColor: theme.centerChannelBg, - }, - topBar: { - visible: true, - height: null, - title: { - color: theme.sidebarHeaderTextColor, - text: title, - }, - }, - }; - - showModalOverCurrentContext(screen, passProps, options); - }); - }; - goToChannelInfo = preventDoubleTap(() => { const {intl} = this.context; const {theme} = this.props; @@ -249,6 +239,62 @@ export default class ChannelBase extends PureComponent { return null; } + showTermsOfServiceModal = async () => { + const {intl} = this.context; + const {isSupportedServer, theme} = this.props; + const screen = 'TermsOfService'; + const title = intl.formatMessage({id: 'mobile.tos_link', defaultMessage: 'Terms of Service'}); + MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((closeButton) => { + const passProps = { + closeButton, + isSupportedServer, + showUnsupportedServer: this.showUnsupportedServer, + }; + const options = { + layout: { + componentBackgroundColor: theme.centerChannelBg, + }, + topBar: { + visible: true, + height: null, + title: { + color: theme.sidebarHeaderTextColor, + text: title, + }, + }, + }; + + showModalOverCurrentContext(screen, passProps, options); + }); + }; + + showUnsupportedServer = () => { + const {formatMessage} = this.context.intl; + const title = formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}); + const message = formatMessage({ + id: 'mobile.server_upgrade.alert_description', + defaultMessage: 'This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.', + }, {serverVersion: ViewTypes.RequiredServer.FULL_VERSION}); + const cancel = { + text: formatMessage({id: 'mobile.server_upgrade.dismiss', defaultMessage: 'Dismiss'}), + style: 'default', + }; + const learnMore = { + text: formatMessage({id: 'mobile.server_upgrade.learn_more', defaultMessage: 'Learn More'}), + style: 'cancel', + onPress: () => { + const url = 'https://mattermost.com/blog/support-for-esr-5-9-has-ended/'; + if (Linking.canOpenURL(url)) { + Linking.openURL(url); + } + }, + }; + const buttons = [cancel, learnMore]; + const options = {cancelable: false}; + + Alert.alert(title, message, buttons, options); + }; + updateNativeScrollView = () => { if (this.keyboardTracker?.current) { this.keyboardTracker.current.resetScrollView(this.props.currentChannelId); diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js index 975d56703..c2b22b943 100644 --- a/app/screens/channel/channel_base.test.js +++ b/app/screens/channel/channel_base.test.js @@ -62,6 +62,7 @@ describe('ChannelBase', () => { const wrapper = shallow( , + {context: {intl: {formatMessage: jest.fn()}}}, ); expect(mergeNavigationOptions.mock.calls).toEqual([]); diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index d276db377..27ba47077 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -7,20 +7,37 @@ import {connect} from 'react-redux'; import {loadChannelsForTeam, selectInitialChannel} from '@actions/views/channel'; import {recordLoadTime} from '@actions/views/root'; import {selectDefaultTeam} from '@actions/views/select_team'; -import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; -import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; -import {shouldShowTermsOfService} from '@mm-redux/selectors/entities/users'; +import {ViewTypes} from '@constants'; import {getChannelStats} from '@mm-redux/actions/channels'; +import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; +import {getServerVersion} from '@mm-redux/selectors/entities/general'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; +import {getCurrentUserId, getCurrentUserRoles, shouldShowTermsOfService} from '@mm-redux/selectors/entities/users'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils'; import Channel from './channel'; function mapStateToProps(state) { const currentTeam = getCurrentTeam(state); + const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : ''; + const isSystemAdmin = checkIsSystemAdmin(roles); + let isSupportedServer = true; + + if (isSystemAdmin) { + isSupportedServer = isMinimumServerVersion( + getServerVersion(state), + ViewTypes.RequiredServer.MAJOR_VERSION, + ViewTypes.RequiredServer.MIN_VERSION, + ViewTypes.RequiredServer.PATCH_VERSION, + ); + } return { currentTeamId: currentTeam?.id, currentChannelId: getCurrentChannelId(state), + isSupportedServer, teamName: currentTeam?.display_name, theme: getTheme(state), showTermsOfService: shouldShowTermsOfService(state), diff --git a/app/screens/terms_of_service/terms_of_service.js b/app/screens/terms_of_service/terms_of_service.js index 93217da06..541aa928c 100644 --- a/app/screens/terms_of_service/terms_of_service.js +++ b/app/screens/terms_of_service/terms_of_service.js @@ -30,6 +30,8 @@ export default class TermsOfService extends PureComponent { }).isRequired, componentId: PropTypes.string, closeButton: PropTypes.object, + isSupportedServer: PropTypes.bool, + showUnsupportedServer: PropTypes.func, siteName: PropTypes.string, theme: PropTypes.object, }; @@ -145,11 +147,18 @@ export default class TermsOfService extends PureComponent { }; handleAcceptTerms = () => { + const onSuccess = () => { + const {isSupportedServer, showUnsupportedServer} = this.props; + dismissModal(); + + if (!isSupportedServer) { + showUnsupportedServer(); + } + }; + this.registerUserAction( true, - () => { - dismissModal(); - }, + onSuccess, this.handleAcceptTerms, ); }; @@ -189,7 +198,6 @@ export default class TermsOfService extends PureComponent { }); const {data} = await actions.updateMyTermsOfServiceStatus(this.state.termsId, accepted); - this.setState({ loading: false, }); diff --git a/app/screens/terms_of_service/terms_of_service.test.js b/app/screens/terms_of_service/terms_of_service.test.js index 4ce021837..4bb650ab0 100644 --- a/app/screens/terms_of_service/terms_of_service.test.js +++ b/app/screens/terms_of_service/terms_of_service.test.js @@ -7,6 +7,7 @@ import {shallow} from 'enzyme'; import Preferences from '@mm-redux/constants/preferences'; import * as NavigationActions from '@actions/navigation'; +import TestHelper from 'test/test_helper'; import TermsOfService from './terms_of_service.js'; @@ -23,16 +24,18 @@ jest.mock('@utils/theme', () => { describe('TermsOfService', () => { const actions = { getTermsOfService: jest.fn(), - updateMyTermsOfServiceStatus: jest.fn(), + updateMyTermsOfServiceStatus: jest.fn().mockResolvedValue({data: true}), logout: jest.fn(), }; const baseProps = { actions, - theme: Preferences.THEMES.default, closeButton: {}, - siteName: 'Mattermost', componentId: 'component-id', + isSupportedServer: true, + siteName: 'Mattermost', + showUnsupportedServer: jest.fn(), + theme: Preferences.THEMES.default, }; test('should match snapshot', () => { @@ -130,4 +133,35 @@ describe('TermsOfService', () => { wrapper.instance().closeTermsAndLogout(); expect(baseProps.actions.logout).toHaveBeenCalledTimes(1); }); + + test('should NOT call showUnsupportedServer on acceptTerms if server is supported', async () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + wrapper.instance().handleAcceptTerms(); + TestHelper.wait(100).then(() => { + expect(baseProps.showUnsupportedServer).not.toHaveBeenCalled(); + }); + }); + + test('should call showUnsupportedServer on acceptTerms if server not supported', async () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + wrapper.instance().handleAcceptTerms(); + TestHelper.wait(100).then(() => { + expect(baseProps.showUnsupportedServer).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f25743d4a..c910949a3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -453,8 +453,11 @@ "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", "mobile.server_ssl.error.text": "The certificate from {host} is not trusted.\n\nPlease contact your System Administrator to resolve the certificate issues and allow connections to this server.", "mobile.server_ssl.error.title": "Untrusted Certificate", + "mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.", "mobile.server_upgrade.button": "OK", "mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n", + "mobile.server_upgrade.dismiss": "Dismiss", + "mobile.server_upgrade.learn_more": "Learn More", "mobile.server_upgrade.title": "Server upgrade required", "mobile.server_url.invalid_format": "URL must start with http:// or https://", "mobile.session_expired": "Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.",