Network consolidation (#2324)

* Create network listener component

* Remove unnecessary network/websocket logic

* Remove offline indicator. Fix linting issue

* Fix websocketStatus prop issue

* Refactor and remove animation from state

* Fix AppState listener. Add connection retry.
This commit is contained in:
Chris Duarte 2018-11-14 13:19:59 -08:00 committed by Elias Nahum
parent ae629a455c
commit 4d5b49f104
4 changed files with 162 additions and 181 deletions

View file

@ -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);

View file

@ -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 = (
<TouchableOpacity
onPress={this.connect}
@ -228,7 +288,7 @@ export default class OfflineIndicator extends Component {
</TouchableOpacity>
);
break;
case CONNECTING:
case RequestStatus.STARTED:
i18nId = t('mobile.offlineIndicator.connecting');
defaultMessage = 'Connecting...';
action = (
@ -240,7 +300,7 @@ export default class OfflineIndicator extends Component {
</View>
);
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 (
<Animated.View style={[styles.container, {top: this.state.top, backgroundColor: background}]}>
<Animated.View style={[styles.container, {top: this.top, backgroundColor: background}]}>
<Animated.View style={styles.wrapper}>
<FormattedText
defaultMessage={defaultMessage}
id={i18nId}
values={values}
style={styles.message}
/>
{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',

View file

@ -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 {
>
<SafeAreaView navigator={navigator}>
<StatusBar/>
<OfflineIndicator/>
<NetworkIndicator/>
<ChannelNavBar
navigator={navigator}
openChannelDrawer={this.openChannelSidebar}

View file

@ -325,7 +325,7 @@
"mobile.notification.in": " in ",
"mobile.offlineIndicator.connected": "Connected",
"mobile.offlineIndicator.connecting": "Connecting...",
"mobile.offlineIndicator.offline": "Cannot connect to the server",
"mobile.offlineIndicator.offline": "Cannot connect to the server. Retrying in {seconds} seconds",
"mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
"mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.",
"mobile.open_unknown_channel.error": "Unable to join the channel. Please reset the cache and try again.",