diff --git a/app/components/offline_indicator/index.js b/app/components/network_indicator/index.js similarity index 53% rename from app/components/offline_indicator/index.js rename to app/components/network_indicator/index.js index 6d2121a61..1a335d86e 100644 --- a/app/components/offline_indicator/index.js +++ b/app/components/network_indicator/index.js @@ -4,33 +4,37 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {init as initWebSocket} from 'mattermost-redux/actions/websocket'; +import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates, logout} from 'mattermost-redux/actions/users'; +import {init as initWebSocket, close as closeWebSocket} from 'mattermost-redux/actions/websocket'; import {connection} from 'app/actions/device'; import {getConnection, isLandscape} from 'app/selectors/device'; -import OfflineIndicator from './offline_indicator'; +import NetworkIndicator from './network_indicator'; function mapStateToProps(state) { const {websocket} = state.requests.general; - const webSocketStatus = websocket.status; - const isConnecting = websocket.error > 1; + const websocketStatus = websocket.status; return { - isConnecting, isLandscape: isLandscape(state), isOnline: getConnection(state), - webSocketStatus, + websocketErrorCount: websocket.error, + websocketStatus, }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + closeWebSocket, connection, initWebSocket, + logout, + startPeriodicStatusUpdates, + stopPeriodicStatusUpdates, }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps)(OfflineIndicator); +export default connect(mapStateToProps, mapDispatchToProps)(NetworkIndicator); diff --git a/app/components/offline_indicator/offline_indicator.js b/app/components/network_indicator/network_indicator.js similarity index 50% rename from app/components/offline_indicator/offline_indicator.js rename to app/components/network_indicator/network_indicator.js index 27590438b..f178150cc 100644 --- a/app/components/offline_indicator/offline_indicator.js +++ b/app/components/network_indicator/network_indicator.js @@ -1,11 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {Component} from 'react'; +import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; import { ActivityIndicator, + Alert, Animated, + AppState, Platform, StyleSheet, TouchableOpacity, @@ -16,16 +19,16 @@ import IonIcon from 'react-native-vector-icons/Ionicons'; import FormattedText from 'app/components/formatted_text'; import {DeviceTypes, ViewTypes} from 'app/constants'; import mattermostBucket from 'app/mattermost_bucket'; -import checkNetwork from 'app/utils/network'; +import networkConnectionListener, {checkConnection} from 'app/utils/network'; import {t} from 'app/utils/i18n'; import LocalConfig from 'assets/config'; import {RequestStatus} from 'mattermost-redux/constants'; const HEIGHT = 38; -const OFFLINE = 'offline'; -const CONNECTING = 'connecting'; -const CONNECTED = 'connected'; +const MAX_WEBSOCKET_RETRIES = 3; +const CONNECTION_RETRY_SECONDS = 30; +const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 30 seconds const { ANDROID_TOP_LANDSCAPE, ANDROID_TOP_PORTRAIT, @@ -35,85 +38,96 @@ const { STATUS_BAR_HEIGHT, } = ViewTypes; -export default class OfflineIndicator extends Component { +export default class NetworkIndicator extends PureComponent { static propTypes = { actions: PropTypes.shape({ + closeWebSocket: PropTypes.func.isRequired, connection: PropTypes.func.isRequired, initWebSocket: PropTypes.func.isRequired, + logout: PropTypes.func.isRequired, + startPeriodicStatusUpdates: PropTypes.func.isRequired, + stopPeriodicStatusUpdates: PropTypes.func.isRequired, }).isRequired, - isConnecting: PropTypes.bool, isLandscape: PropTypes.bool, isOnline: PropTypes.bool, - webSocketStatus: PropTypes.string, + websocketErrorCount: PropTypes.number, + websocketStatus: PropTypes.string, }; static defaultProps = { isOnline: true, }; + static contextTypes = { + intl: intlShape.isRequired, + }; + constructor(props) { super(props); const navBar = this.getNavBarHeight(props.isLandscape); - - this.state = { - network: null, - navBar, - top: new Animated.Value(navBar - HEIGHT), - }; + this.top = new Animated.Value(navBar - HEIGHT); this.backgroundColor = new Animated.Value(0); + + this.networkListener = networkConnectionListener(this.handleConnectionChange); } componentDidMount() { this.mounted = true; + + AppState.addEventListener('change', this.handleAppStateChange); } - componentWillReceiveProps(nextProps) { - const {isLandscape, webSocketStatus} = this.props; + componentDidUpdate(prevProps) { + const {websocketStatus: previousWebsocketStatus} = prevProps; + const {websocketErrorCount, websocketStatus} = this.props; - if (nextProps.isLandscape !== isLandscape && this.state.network) { - const navBar = this.getNavBarHeight(nextProps.isLandscape); - const top = new Animated.Value(navBar - HEIGHT); - this.setLocalState({navBar, top}); - } - - if (nextProps.isOnline) { - if (this.state.network && webSocketStatus === RequestStatus.STARTED && nextProps.webSocketStatus === RequestStatus.SUCCESS) { + if (this.props.isOnline) { + if (previousWebsocketStatus === RequestStatus.STARTED && websocketStatus === RequestStatus.SUCCESS) { // Show the connected animation only if we had a previous network status this.connected(); - } else if (webSocketStatus === RequestStatus.STARTED && nextProps.webSocketStatus === RequestStatus.FAILURE && nextProps.isConnecting) { - // Show the connecting bar if it failed to connect at least twice - this.connecting(); + clearTimeout(this.connectionRetryTimeout); + } else if (previousWebsocketStatus === RequestStatus.STARTED && websocketStatus === RequestStatus.FAILURE && websocketErrorCount > MAX_WEBSOCKET_RETRIES) { + this.handleWebSocket(false); + this.handleReconnect(); + } else if (websocketStatus === RequestStatus.FAILURE && websocketErrorCount > 1) { + this.show(); } - } else { + } else if (prevProps.isOnline && !this.props.isOnline) { this.offline(); } } - shouldComponentUpdate(nextProps, nextState) { - return (nextState.network !== this.state.network || nextProps.isLandscape !== this.props.isLandscape); - } - componentWillUnmount() { this.mounted = false; + + const {closeWebSocket, stopPeriodicStatusUpdates} = this.props.actions; + + this.networkListener.removeEventListener(); + + AppState.removeEventListener('change', this.handleAppStateChange); + + closeWebSocket(); + stopPeriodicStatusUpdates(); + + clearTimeout(this.connectionRetryTimeout); } - connect = () => { - checkNetwork((result) => { - this.setLocalState({network: CONNECTING}, () => { - if (result) { - this.props.actions.connection(true); - this.initializeWebSocket(); - } else { - this.setLocalState({network: OFFLINE}); - } - }); - }); + connect = async () => { + clearTimeout(this.connectionRetryTimeout); + + const result = await checkConnection(this.props.isOnline); + + if (result) { + this.props.actions.connection(true); + this.initializeWebSocket(); + } else { + this.handleWebSocket(false); + } }; connected = () => { - this.setState({network: CONNECTED}); Animated.sequence([ Animated.timing( this.backgroundColor, { @@ -122,28 +136,18 @@ export default class OfflineIndicator extends Component { } ), Animated.timing( - this.state.top, { - toValue: (this.state.navBar - HEIGHT), + this.top, { + toValue: (this.getNavBarHeight() - HEIGHT), duration: 300, delay: 500, } ), ]).start(() => { this.backgroundColor.setValue(0); - this.setLocalState({network: null}); }); }; - connecting = () => { - const prevState = this.state.network; - this.setLocalState({network: CONNECTING}, () => { - if (prevState !== OFFLINE) { - this.show(); - } - }); - }; - - getNavBarHeight = (isLandscape) => { + getNavBarHeight = (isLandscape = this.props.isLandscape) => { if (Platform.OS === 'android') { if (isLandscape) { return ANDROID_TOP_LANDSCAPE; @@ -152,9 +156,11 @@ export default class OfflineIndicator extends Component { return ANDROID_TOP_PORTRAIT; } - if (DeviceTypes.IS_IPHONE_X && isLandscape) { + const isX = DeviceTypes.IS_IPHONE_X; + + if (isX && isLandscape) { return IOS_TOP_LANDSCAPE; - } else if (DeviceTypes.IS_IPHONE_X) { + } else if (isX) { return IOSX_TOP_PORTRAIT; } else if (isLandscape) { return IOS_TOP_LANDSCAPE + STATUS_BAR_HEIGHT; @@ -163,46 +169,94 @@ export default class OfflineIndicator extends Component { return IOS_TOP_PORTRAIT; }; - initializeWebSocket = async () => { + handleWebSocket = (open) => { const {actions} = this.props; - const {initWebSocket} = actions; + const { + closeWebSocket, + startPeriodicStatusUpdates, + stopPeriodicStatusUpdates, + } = actions; + + if (open) { + this.initializeWebSocket(); + startPeriodicStatusUpdates(); + } else { + closeWebSocket(true); + stopPeriodicStatusUpdates(); + } + }; + + handleAppStateChange = async (appState) => { + this.handleWebSocket(appState === 'active'); + }; + + handleConnectionChange = (isConnected) => { + const {connection} = this.props.actions; + + // Prevent for being called more than once. + if (this.isConnected !== isConnected) { + this.isConnected = isConnected; + this.handleWebSocket(isConnected); + connection(isConnected); + } + }; + + handleReconnect = () => { + clearTimeout(this.connectionRetryTimeout); + this.connectionRetryTimeout = setTimeout(() => { + const {websocketStatus} = this.props; + if (websocketStatus !== RequestStatus.STARTED || websocketStatus !== RequestStatus.SUCCESS) { + this.connect(); + } + }, CONNECTION_RETRY_TIMEOUT); + } + + initializeWebSocket = async () => { + const {formatMessage} = this.context.intl; + const {actions} = this.props; + const {closeWebSocket, initWebSocket} = actions; const platform = Platform.OS; let certificate = null; if (platform === 'ios') { certificate = await mattermostBucket.getPreference('cert', LocalConfig.AppGroupId); } - initWebSocket(platform, null, null, null, {certificate}); - }; - - offline = () => { - this.setLocalState({network: OFFLINE}, () => { - this.show(); + initWebSocket(platform, null, null, null, {certificate}).catch(() => { + // we should dispatch a failure and show the app as disconnected + Alert.alert( + formatMessage({id: 'mobile.authentication_error.title', defaultMessage: 'Authentication Error'}), + formatMessage({ + id: 'mobile.authentication_error.message', + defaultMessage: 'Mattermost has encountered an error. Please re-authenticate to start a new session.', + }), + [{ + text: formatMessage({ + id: 'navbar_dropdown.logout', + defaultMessage: 'Logout', + }), + onPress: actions.logout, + }], + {cancelable: false} + ); + closeWebSocket(true); }); }; - setLocalState = (state, callback) => { - if (!this.mounted) { - return; - } - - this.setState(state, callback); + offline = () => { + this.show(); }; show = () => { Animated.timing( - this.state.top, { - toValue: this.state.navBar, + this.top, { + toValue: this.getNavBarHeight(), duration: 300, } ).start(); }; render() { - if (!this.state.network) { - return null; - } - + const {websocketErrorCount, websocketStatus} = this.props; const background = this.backgroundColor.interpolate({ inputRange: [0, 1], outputRange: ['#939393', '#629a41'], @@ -210,11 +264,17 @@ export default class OfflineIndicator extends Component { let i18nId; let defaultMessage; + let values; let action; - switch (this.state.network) { - case OFFLINE: + + const currentWebsocketStatus = (websocketStatus === RequestStatus.FAILURE && websocketErrorCount <= MAX_WEBSOCKET_RETRIES) ? RequestStatus.STARTED : websocketStatus; + + switch (currentWebsocketStatus) { + case (RequestStatus.NOT_STARTED): + case (RequestStatus.FAILURE): i18nId = t('mobile.offlineIndicator.offline'); - defaultMessage = 'Cannot connect to the server'; + defaultMessage = 'Cannot connect to the server. Retrying in {seconds} seconds'; + values = {seconds: CONNECTION_RETRY_SECONDS}; action = ( ); break; - case CONNECTING: + case RequestStatus.STARTED: i18nId = t('mobile.offlineIndicator.connecting'); defaultMessage = 'Connecting...'; action = ( @@ -240,7 +300,7 @@ export default class OfflineIndicator extends Component { ); break; - case CONNECTED: + case RequestStatus.SUCCESS: default: i18nId = t('mobile.offlineIndicator.connected'); defaultMessage = 'Connected'; @@ -257,11 +317,12 @@ export default class OfflineIndicator extends Component { } return ( - + {action} @@ -288,7 +349,7 @@ const styles = StyleSheet.create({ }, message: { color: '#FFFFFF', - fontSize: 14, + fontSize: 12, fontWeight: '600', flex: 1, }, @@ -296,6 +357,7 @@ const styles = StyleSheet.create({ alignItems: 'center', borderWidth: 1, borderColor: '#FFFFFF', + paddingRight: 0, }, actionContainer: { alignItems: 'flex-end', diff --git a/app/screens/channel/channel.js b/app/screens/channel/channel.js index a2e1bf995..482a98fe4 100644 --- a/app/screens/channel/channel.js +++ b/app/screens/channel/channel.js @@ -5,8 +5,6 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; import { - Alert, - AppState, Dimensions, Platform, StyleSheet, @@ -21,14 +19,12 @@ import ChannelLoader from 'app/components/channel_loader'; import MainSidebar from 'app/components/sidebars/main'; import SettingsSidebar from 'app/components/sidebars/settings'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; -import OfflineIndicator from 'app/components/offline_indicator'; +import NetworkIndicator from 'app/components/network_indicator'; import SafeAreaView from 'app/components/safe_area_view'; import StatusBar from 'app/components/status_bar'; import {DeviceTypes, ViewTypes} from 'app/constants'; -import mattermostBucket from 'app/mattermost_bucket'; import {preventDoubleTap} from 'app/utils/tap'; import PostTextbox from 'app/components/post_textbox'; -import networkConnectionListener from 'app/utils/network'; import tracker from 'app/utils/time_tracker'; import LocalConfig from 'assets/config'; @@ -48,17 +44,11 @@ let ClientUpgradeListener; export default class Channel extends PureComponent { static propTypes = { actions: PropTypes.shape({ - connection: PropTypes.func.isRequired, loadChannelsIfNecessary: PropTypes.func.isRequired, loadProfilesAndTeamMembersForDMSidebar: PropTypes.func.isRequired, - logout: PropTypes.func.isRequired, selectDefaultTeam: PropTypes.func.isRequired, selectInitialChannel: PropTypes.func.isRequired, - initWebSocket: PropTypes.func.isRequired, - closeWebSocket: PropTypes.func.isRequired, recordLoadTime: PropTypes.func.isRequired, - startPeriodicStatusUpdates: PropTypes.func.isRequired, - stopPeriodicStatusUpdates: PropTypes.func.isRequired, }).isRequired, currentChannelId: PropTypes.string, channelsRequestFailed: PropTypes.bool, @@ -89,8 +79,6 @@ export default class Channel extends PureComponent { componentWillMount() { EventEmitter.on('leave_team', this.handleLeaveTeam); - this.networkListener = networkConnectionListener(this.handleConnectionChange); - if (this.props.currentTeamId) { this.loadChannels(this.props.currentTeamId); } else { @@ -99,8 +87,6 @@ export default class Channel extends PureComponent { } componentDidMount() { - AppState.addEventListener('change', this.handleAppStateChange); - if (tracker.initialLoad) { this.props.actions.recordLoadTime('Start time', 'initialLoad'); } @@ -140,15 +126,7 @@ export default class Channel extends PureComponent { } componentWillUnmount() { - const {closeWebSocket, stopPeriodicStatusUpdates} = this.props.actions; - EventEmitter.off('leave_team', this.handleLeaveTeam); - this.networkListener.removeEventListener(); - - AppState.removeEventListener('change', this.handleAppStateChange); - - closeWebSocket(); - stopPeriodicStatusUpdates(); } attachPostTextBox = (ref) => { @@ -245,73 +223,10 @@ export default class Channel extends PureComponent { } }); - handleWebSocket = (open) => { - const {actions} = this.props; - const { - closeWebSocket, - startPeriodicStatusUpdates, - stopPeriodicStatusUpdates, - } = actions; - - if (open) { - this.initializeWebSocket(); - startPeriodicStatusUpdates(); - } else { - closeWebSocket(true); - stopPeriodicStatusUpdates(); - } - }; - - handleAppStateChange = async (appState) => { - this.handleWebSocket(appState === 'active'); - }; - - handleConnectionChange = (isConnected) => { - const {connection} = this.props.actions; - - // Prevent for being called more than once. - if (this.isConnected !== isConnected) { - this.isConnected = isConnected; - this.handleWebSocket(isConnected); - connection(isConnected); - } - }; - handleLeaveTeam = () => { this.props.actions.selectDefaultTeam(); }; - initializeWebSocket = async () => { - const {formatMessage} = this.context.intl; - const {actions} = this.props; - const {initWebSocket} = actions; - const platform = Platform.OS; - let certificate = null; - if (platform === 'ios') { - certificate = await mattermostBucket.getPreference('cert', LocalConfig.AppGroupId); - } - - initWebSocket(platform, null, null, null, {certificate}).catch(() => { - // we should dispatch a failure and show the app as disconnected - Alert.alert( - formatMessage({id: 'mobile.authentication_error.title', defaultMessage: 'Authentication Error'}), - formatMessage({ - id: 'mobile.authentication_error.message', - defaultMessage: 'Mattermost has encountered an error. Please re-authenticate to start a new session.', - }), - [{ - text: formatMessage({ - id: 'navbar_dropdown.logout', - defaultMessage: 'Logout', - }), - onPress: actions.logout, - }], - {cancelable: false} - ); - this.props.actions.closeWebSocket(true); - }); - }; - loadChannels = (teamId) => { const { loadChannelsIfNecessary, @@ -393,7 +308,7 @@ export default class Channel extends PureComponent { > - +