RN-61 Implement offline indicator (#472)

* Implement offline indicator

* Review feedback
This commit is contained in:
enahum 2017-04-10 09:17:43 -03:00 committed by GitHub
parent c675b17e3c
commit 0d654a79da
9 changed files with 358 additions and 29 deletions

View file

@ -16,6 +16,7 @@
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="com.google.android.c2dm.permission.SEND" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-sdk
android:minSdkVersion="16"
android:targetSdkVersion="22" />

View file

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

View file

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

View file

@ -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 = (
<TouchableOpacity
onPress={this.connect}
style={[styles.actionContainer, styles.actionButton]}
>
<IonIcon
color='#FFFFFF'
name='ios-refresh'
size={20}
/>
</TouchableOpacity>
);
break;
case CONNECTING:
i18nId = 'mobile.offlineIndicator.connecting';
defaultMessage = 'Connecting...';
action = (
<View style={[styles.actionContainer, styles.actionButton]}>
<ActivityIndicator
color='#FFFFFF'
size='small'
/>
</View>
);
break;
case CONNECTED:
i18nId = 'mobile.offlineIndicator.connected';
defaultMessage = 'Connected';
action = (
<View style={styles.actionContainer}>
<IonIcon
color='#FFFFFF'
name='md-checkmark'
size={20}
/>
</View>
);
break;
}
return (
<Animated.View style={[styles.container, {top: this.state.top}]}>
<Animated.View style={[styles.wrapper, {backgroundColor: background}]}>
<FormattedText
defaultMessage={defaultMessage}
id={i18nId}
style={styles.message}
/>
{action}
</Animated.View>
</Animated.View>
);
}
}
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
}
});

View file

@ -18,6 +18,8 @@ const ViewTypes = keyMirror({
NOTIFICATION_CHANGED: null,
CONNECTION_CHANGED: null,
SET_POST_DRAFT: null,
SET_COMMENT_DRAFT: null,

View file

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

View file

@ -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,

View file

@ -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}
>
<StatusBar barStyle='light-content'/>
<OfflineIndicator/>
<ChannelPostList channel={currentChannel}/>
<PostTextbox
ref={this.attachPostTextbox}

View file

@ -16,11 +16,12 @@ import {
selectInitialChannel,
handlePostDraftChanged
} from 'app/actions/views/channel';
import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'mattermost-redux/actions/users';
import {connection} from 'app/actions/views/connection';
import {selectFirstAvailableTeam} from 'app/actions/views/select_team';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'app/selectors/preferences';
import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'mattermost-redux/actions/users';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {
@ -31,18 +32,22 @@ import {
import Channel from './channel';
function mapStateToProps(state, ownProps) {
const {websocket} = state.requests.general;
return {
...ownProps,
...state.views.channel,
currentTeam: getCurrentTeam(state),
currentChannel: getCurrentChannel(state),
theme: getTheme(state)
theme: getTheme(state),
webSocketRequest: websocket
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
connection,
loadChannelsIfNecessary,
loadProfilesAndTeamMembersForDMSidebar,
selectFirstAvailableTeam,