RN-61 Implement offline indicator (#472)
* Implement offline indicator * Review feedback
This commit is contained in:
parent
c675b17e3c
commit
0d654a79da
9 changed files with 358 additions and 29 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
13
app/actions/views/connection.js
Normal file
13
app/actions/views/connection.js
Normal 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);
|
||||
};
|
||||
}
|
||||
33
app/components/offline_indicator/index.js
Normal file
33
app/components/offline_indicator/index.js
Normal 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);
|
||||
230
app/components/offline_indicator/offline_indicator.js
Normal file
230
app/components/offline_indicator/offline_indicator.js
Normal 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
|
||||
}
|
||||
});
|
||||
|
|
@ -18,6 +18,8 @@ const ViewTypes = keyMirror({
|
|||
|
||||
NOTIFICATION_CHANGED: null,
|
||||
|
||||
CONNECTION_CHANGED: null,
|
||||
|
||||
SET_POST_DRAFT: null,
|
||||
SET_COMMENT_DRAFT: null,
|
||||
|
||||
|
|
|
|||
18
app/reducers/views/connection.js
Normal file
18
app/reducers/views/connection.js
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue