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} > +