diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7eca6af1d..3f2108031 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,7 @@
+
diff --git a/app/actions/views/connection.js b/app/actions/views/connection.js
new file mode 100644
index 000000000..8c3713c01
--- /dev/null
+++ b/app/actions/views/connection.js
@@ -0,0 +1,13 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {ViewTypes} from 'app/constants';
+
+export function connection(isOnline) {
+ return async (dispatch, getState) => {
+ dispatch({
+ type: ViewTypes.CONNECTION_CHANGED,
+ data: isOnline
+ }, getState);
+ };
+}
diff --git a/app/components/offline_indicator/index.js b/app/components/offline_indicator/index.js
new file mode 100644
index 000000000..5fd142fca
--- /dev/null
+++ b/app/components/offline_indicator/index.js
@@ -0,0 +1,33 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {bindActionCreators} from 'redux';
+import {connect} from 'react-redux';
+
+import {close as closeWebSocket, init as initWebSocket} from 'mattermost-redux/actions/websocket';
+
+import OfflineIndicator from './offline_indicator';
+
+function mapStateToProps(state, ownProps) {
+ const {websocket} = state.requests.general;
+ const {appState} = state.entities.general;
+ const {connection} = state.views;
+
+ return {
+ appState,
+ isOnline: connection,
+ websocket,
+ ...ownProps
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ closeWebSocket,
+ initWebSocket
+ }, dispatch)
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(OfflineIndicator);
diff --git a/app/components/offline_indicator/offline_indicator.js b/app/components/offline_indicator/offline_indicator.js
new file mode 100644
index 000000000..0e6d53177
--- /dev/null
+++ b/app/components/offline_indicator/offline_indicator.js
@@ -0,0 +1,230 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {PropTypes, PureComponent} from 'react';
+import {
+ ActivityIndicator,
+ Animated,
+ Dimensions,
+ Platform,
+ StyleSheet,
+ TouchableOpacity,
+ View
+} from 'react-native';
+import IonIcon from 'react-native-vector-icons/Ionicons';
+
+import FormattedText from 'app/components/formatted_text';
+
+import {RequestStatus} from 'mattermost-redux/constants';
+
+const INITIAL_TOP = Platform.OS === 'ios' ? -80 : -60;
+const OFFLINE = 'offline';
+const CONNECTING = 'connecting';
+const CONNECTED = 'connected';
+
+export default class OfflineIndicator extends PureComponent {
+ static propTypes = {
+ actions: PropTypes.shape({
+ closeWebSocket: PropTypes.func.isRequired,
+ initWebSocket: PropTypes.func.isRequired
+ }).isRequired,
+ appState: PropTypes.bool,
+ isOnline: PropTypes.bool,
+ websocket: PropTypes.object
+ };
+
+ static defaultProps: {
+ appState: true,
+ isOnline: true
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ forced: false,
+ network: null,
+ top: new Animated.Value(INITIAL_TOP)
+ };
+
+ this.backgroundColor = new Animated.Value(0);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.props.appState || nextProps.appState) {
+ if (this.state.forced || nextProps.isOnline) {
+ if (nextProps.websocket.status === RequestStatus.STARTED || nextProps.websocket.status === RequestStatus.FAILURE) {
+ this.connecting();
+ } else if (nextProps.websocket.status === RequestStatus.SUCCESS) {
+ this.connected();
+ }
+ } else {
+ this.offline();
+ }
+ }
+ }
+
+ offline = () => {
+ this.setState({network: OFFLINE}, () => {
+ this.show();
+ });
+ };
+
+ connect = () => {
+ const {closeWebSocket, initWebSocket} = this.props.actions;
+ this.setState({forced: true}, () => {
+ initWebSocket(Platform.OS);
+
+ // set forced to be false after trying for 3 seconds
+ setTimeout(() => {
+ closeWebSocket();
+ this.setState({forced: false, network: OFFLINE});
+ }, 3000);
+ });
+ };
+
+ connecting = () => {
+ const prevState = this.state.network;
+ this.setState({network: CONNECTING}, () => {
+ if (prevState !== OFFLINE) {
+ this.show();
+ }
+ });
+ };
+
+ connected = () => {
+ this.setState({network: CONNECTED});
+ Animated.sequence([
+ Animated.timing(
+ this.backgroundColor, {
+ toValue: 1,
+ duration: 100
+ }
+ ),
+ Animated.timing(
+ this.state.top, {
+ toValue: INITIAL_TOP,
+ duration: 300,
+ delay: 1000
+ }
+ )
+ ]).start(() => {
+ this.backgroundColor.setValue(0);
+ this.setState({forced: false});
+ });
+ };
+
+ show = () => {
+ Animated.timing(
+ this.state.top, {
+ toValue: 0,
+ duration: 300
+ }
+ ).start();
+ };
+
+ render() {
+ if (!this.state.network) {
+ return null;
+ }
+
+ const background = this.backgroundColor.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['#939393', '#629a41']
+ });
+
+ let i18nId;
+ let defaultMessage;
+ let action;
+ switch (this.state.network) {
+ case OFFLINE:
+ i18nId = 'mobile.offlineIndicator.offline';
+ defaultMessage = 'No Internet connection';
+ action = (
+
+
+
+ );
+ break;
+ case CONNECTING:
+ i18nId = 'mobile.offlineIndicator.connecting';
+ defaultMessage = 'Connecting...';
+ action = (
+
+
+
+ );
+ break;
+ case CONNECTED:
+ i18nId = 'mobile.offlineIndicator.connected';
+ defaultMessage = 'Connected';
+ action = (
+
+
+
+ );
+ break;
+ }
+
+ return (
+
+
+
+ {action}
+
+
+ );
+ }
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ zIndex: 10,
+ position: 'absolute',
+ elevation: 2
+ },
+ wrapper: {
+ alignItems: 'center',
+ flex: 1,
+ flexDirection: 'row',
+ height: 38,
+ paddingHorizontal: 12,
+ width: Dimensions.get('window').width,
+ backgroundColor: 'red'
+ },
+ message: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ fontWeight: '600',
+ flex: 1
+ },
+ actionButton: {
+ borderWidth: 1,
+ borderColor: '#FFFFFF'
+ },
+ actionContainer: {
+ alignItems: 'center',
+ height: 24,
+ justifyContent: 'center',
+ width: 60
+ }
+});
diff --git a/app/constants/view.js b/app/constants/view.js
index 92fc0b2b1..0b110476c 100644
--- a/app/constants/view.js
+++ b/app/constants/view.js
@@ -18,6 +18,8 @@ const ViewTypes = keyMirror({
NOTIFICATION_CHANGED: null,
+ CONNECTION_CHANGED: null,
+
SET_POST_DRAFT: null,
SET_COMMENT_DRAFT: null,
diff --git a/app/reducers/views/connection.js b/app/reducers/views/connection.js
new file mode 100644
index 000000000..059e6c39d
--- /dev/null
+++ b/app/reducers/views/connection.js
@@ -0,0 +1,18 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {UsersTypes} from 'mattermost-redux/constants';
+import {ViewTypes} from 'app/constants';
+
+export default function connection(state = true, action) {
+ switch (action.type) {
+ case ViewTypes.CONNECTION_CHANGED:
+ return action.data;
+
+ case UsersTypes.LOGOUT_SUCCESS:
+ return true;
+ }
+
+ return state;
+}
+
diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js
index 793abe666..b38d2fca7 100644
--- a/app/reducers/views/index.js
+++ b/app/reducers/views/index.js
@@ -4,6 +4,7 @@
import {combineReducers} from 'redux';
import channel from './channel';
+import connection from './connection';
import fetchCache from './fetch_cache';
import i18n from './i18n';
import login from './login';
@@ -15,6 +16,7 @@ import team from './team';
import thread from './thread';
export default combineReducers({
+ connection,
channel,
fetchCache,
i18n,
diff --git a/app/scenes/channel/channel.js b/app/scenes/channel/channel.js
index a5c275bca..06d11df39 100644
--- a/app/scenes/channel/channel.js
+++ b/app/scenes/channel/channel.js
@@ -1,8 +1,9 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import React from 'react';
+import React, {PropTypes, PureComponent} from 'react';
import {
+ NetInfo,
Platform,
StatusBar,
View
@@ -10,38 +11,42 @@ import {
import KeyboardLayout from 'app/components/layout/keyboard_layout';
import Loading from 'app/components/loading';
+import OfflineIndicator from 'app/components/offline_indicator';
import PostTextbox from 'app/components/post_textbox';
import {preventDoubleTap} from 'app/utils/tap';
-import {Constants} from 'mattermost-redux/constants';
+import {Constants, RequestStatus} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import ChannelDrawerButton from './channel_drawer_button';
import ChannelTitle from './channel_title';
import ChannelPostList from './channel_post_list';
-export default class Channel extends React.PureComponent {
+export default class Channel extends PureComponent {
static propTypes = {
- actions: React.PropTypes.shape({
- loadChannelsIfNecessary: React.PropTypes.func.isRequired,
- loadProfilesAndTeamMembersForDMSidebar: React.PropTypes.func.isRequired,
- selectFirstAvailableTeam: React.PropTypes.func.isRequired,
- selectInitialChannel: React.PropTypes.func.isRequired,
- openChannelDrawer: React.PropTypes.func.isRequired,
- handlePostDraftChanged: React.PropTypes.func.isRequired,
- goToChannelInfo: React.PropTypes.func.isRequired,
- initWebSocket: React.PropTypes.func.isRequired,
- closeWebSocket: React.PropTypes.func.isRequired,
- startPeriodicStatusUpdates: React.PropTypes.func.isRequired,
- stopPeriodicStatusUpdates: React.PropTypes.func.isRequired,
- renderDrawer: React.PropTypes.func.isRequired
+ actions: PropTypes.shape({
+ connection: PropTypes.func.isRequired,
+ loadChannelsIfNecessary: PropTypes.func.isRequired,
+ loadProfilesAndTeamMembersForDMSidebar: PropTypes.func.isRequired,
+ selectFirstAvailableTeam: PropTypes.func.isRequired,
+ selectInitialChannel: PropTypes.func.isRequired,
+ openChannelDrawer: PropTypes.func.isRequired,
+ handlePostDraftChanged: PropTypes.func.isRequired,
+ goToChannelInfo: PropTypes.func.isRequired,
+ initWebSocket: PropTypes.func.isRequired,
+ closeWebSocket: PropTypes.func.isRequired,
+ startPeriodicStatusUpdates: PropTypes.func.isRequired,
+ stopPeriodicStatusUpdates: PropTypes.func.isRequired,
+ renderDrawer: PropTypes.func.isRequired
}).isRequired,
- currentTeam: React.PropTypes.object,
- currentChannel: React.PropTypes.object,
- drafts: React.PropTypes.object.isRequired,
- theme: React.PropTypes.object.isRequired,
- subscribeToHeaderEvent: React.PropTypes.func,
- unsubscribeFromHeaderEvent: React.PropTypes.func
+ appState: PropTypes.bool,
+ currentTeam: PropTypes.object,
+ currentChannel: PropTypes.object,
+ drafts: PropTypes.object.isRequired,
+ theme: PropTypes.object.isRequired,
+ subscribeToHeaderEvent: PropTypes.func,
+ unsubscribeFromHeaderEvent: PropTypes.func,
+ webSocketRequest: PropTypes.object
};
static navigationProps = {
@@ -54,13 +59,17 @@ export default class Channel extends React.PureComponent {
}
};
- canPress = true;
-
componentWillMount() {
this.props.subscribeToHeaderEvent('open_channel_drawer', () => preventDoubleTap(this.openChannelDrawer, this));
this.props.subscribeToHeaderEvent('show_channel_info', () => preventDoubleTap(this.props.actions.goToChannelInfo));
+ NetInfo.isConnected.addEventListener('change', this.handleConnectionChange);
EventEmitter.on('leave_team', this.handleLeaveTeam);
- this.props.actions.initWebSocket(Platform.OS);
+
+ // Android won't detect the initial connection change that's why we need this
+ if (Platform.OS === 'android') {
+ NetInfo.isConnected.fetch().then(this.handleConnectionChange);
+ }
+
if (this.props.currentTeam) {
const teamId = this.props.currentTeam.id;
this.loadChannels(teamId);
@@ -83,9 +92,24 @@ export default class Channel extends React.PureComponent {
this.props.actions.closeWebSocket();
this.props.actions.stopPeriodicStatusUpdates();
this.props.unsubscribeFromHeaderEvent('open_channel_drawer');
+ NetInfo.isConnected.removeEventListener('change', this.handleConnectionChange);
EventEmitter.off('leave_team', this.handleLeaveTeam);
}
+ handleConnectionChange = (isConnected) => {
+ const {actions, webSocketRequest} = this.props;
+ const {closeWebSocket, connection, initWebSocket} = actions;
+
+ if (isConnected) {
+ if (!webSocketRequest || webSocketRequest.status === RequestStatus.NOT_STARTED) {
+ initWebSocket(Platform.OS);
+ }
+ } else {
+ closeWebSocket();
+ }
+ connection(isConnected);
+ };
+
loadChannels = (teamId) => {
this.props.actions.loadChannelsIfNecessary(teamId).then(() => {
this.props.actions.loadProfilesAndTeamMembersForDMSidebar(teamId);
@@ -139,6 +163,7 @@ export default class Channel extends React.PureComponent {
keyboardVerticalOffset={65}
>
+