mattermost-mobile/app/components/network_indicator/network.tsx
Elias Nahum a7b604e8c9
MM-37660 Fix keep mention for channel A when opening a push notification on channel B (#5597)
* Fix keep mention for channel A when opening a push notification on channel B

* properly fix race condition

* Load channels and channel members when opening the app from a push notification
2021-08-05 19:56:36 -07:00

301 lines
9.7 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useNetInfo} from '@react-native-community/netinfo';
import React, {useEffect, useRef, useState} from 'react';
import {ActivityIndicator, AppState, AppStateStatus, Platform, StyleSheet, View} from 'react-native';
import Animated, {Easing, interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {ViewTypes} from '@constants';
import PushNotifications from '@init/push_notifications';
import {debounce} from '@mm-redux/actions/helpers';
import {RequestStatus} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {t} from '@utils/i18n';
import networkConnectionListener, {checkConnection} from '@utils/network';
type Props = {
channelId?: string;
closeWebSocket: (shouldReconnect?: boolean) => void;
errorCount: number | null;
initWebSocket: (additionalOptions: {forceConnection: boolean}) => void;
markChannelViewedAndReadOnReconnect: (channelId: string) => void;
setCurrentUserStatusOffline: () => void;
startPeriodicStatusUpdates: (forceStatusUpdate: boolean) => void;
status: string;
stopPeriodicStatusUpdates: () => void;
}
type AppStateCallBack = (appState: AppStateStatus) => Promise<void>;
type ConnectionChangedEvent = {
hasInternet: boolean;
serverReachable: boolean;
};
const MAX_WEBSOCKET_RETRIES = 3;
const CONNECTION_RETRY_SECONDS = 5;
const CONNECTION_RETRY_TIMEOUT = 1000 * CONNECTION_RETRY_SECONDS; // 5 seconds
const styles = StyleSheet.create({
container: {
height: ViewTypes.INDICATOR_BAR_HEIGHT,
width: '100%',
position: 'absolute',
...Platform.select({
android: {
elevation: 9,
},
ios: {
zIndex: 9,
},
}),
},
wrapper: {
alignItems: 'center',
flex: 1,
height: ViewTypes.INDICATOR_BAR_HEIGHT,
flexDirection: 'row',
paddingLeft: 12,
paddingRight: 5,
},
message: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
flex: 1,
},
actionContainer: {
alignItems: 'flex-end',
height: 24,
justifyContent: 'center',
paddingRight: 10,
width: 60,
},
});
const colors = ['#939393', '#629a41'];
const stateChange = (callback: AppStateCallBack) => (Platform.OS === 'android' ? callback : debounce(callback, 300));
// For Gekidou the WS handler should be implemented with events instead.
// We should have a central place where we manage the WebSocket connections for each server
// and emit events so this component can React to them.
const NetworkIndicator = ({
channelId, closeWebSocket, errorCount, initWebSocket, markChannelViewedAndReadOnReconnect,
setCurrentUserStatusOffline, startPeriodicStatusUpdates, status, stopPeriodicStatusUpdates,
}: Props) => {
const netinfo = useNetInfo();
const firstRun = useRef(true);
const clearNotificationTimeout = useRef<NodeJS.Timeout>();
const retryTimeout = useRef<NodeJS.Timeout>();
const bgColor = useSharedValue(0);
const [connected, setConnected] = useState(true);
const [translateY, setTranslateY] = useState(0);
let i18nId;
let defaultMessage;
let action;
const clearNotifications = () => {
if (channelId) {
PushNotifications.clearChannelNotifications(channelId);
markChannelViewedAndReadOnReconnect(channelId);
}
};
const hanleAnimationFinished = () => {
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, !connected);
};
const handleConnectionChange = ({hasInternet}: ConnectionChangedEvent) => {
if (!firstRun.current) {
if (!hasInternet) {
setConnected(false);
}
handleWebSocket(hasInternet);
}
};
const handleReconnect = () => {
if (retryTimeout.current) {
clearTimeout(retryTimeout.current);
}
retryTimeout.current = setTimeout(async () => {
if (status !== RequestStatus.STARTED || status !== RequestStatus.SUCCESS) {
const {serverReachable} = await checkConnection(netinfo.isInternetReachable);
handleWebSocket(serverReachable);
if (!serverReachable) {
handleReconnect();
}
}
}, CONNECTION_RETRY_TIMEOUT);
};
const handleWebSocket = (connect: boolean) => {
if (connect) {
initWebSocket({forceConnection: true});
startPeriodicStatusUpdates(true);
} else {
closeWebSocket(true);
stopPeriodicStatusUpdates();
setCurrentUserStatusOffline();
}
};
useEffect(() => {
handleWebSocket(true);
firstRun.current = false;
}, []);
useEffect(() => {
const networkListener = networkConnectionListener(handleConnectionChange);
return () => networkListener.removeEventListener();
}, []);
useEffect(() => {
return () => {
handleWebSocket(false);
if (retryTimeout.current) {
clearTimeout(retryTimeout.current);
retryTimeout.current = undefined;
}
};
}, []);
useEffect(() => {
const handleAppStateChange = stateChange(async (appState: AppStateStatus) => {
const active = appState === 'active';
handleWebSocket(active);
if (active) {
// Clear the notifications for the current channel after two seconds
// this is done so we can cancel it in case the app is brought to the
// foreground by tapping a notification from another channel
clearNotificationTimeout.current = setTimeout(clearNotifications, 2000);
}
});
AppState.addEventListener('change', handleAppStateChange);
return () => {
AppState.removeEventListener('change', handleAppStateChange);
if (clearNotificationTimeout.current && AppState.currentState !== 'active') {
clearTimeout(clearNotificationTimeout.current);
}
};
}, [netinfo.isInternetReachable, channelId]);
useEffect(() => {
if (channelId) {
clearNotificationTimeout.current = setTimeout(clearNotifications, 1500);
}
return () => {
if (clearNotificationTimeout.current && channelId) {
clearTimeout(clearNotificationTimeout.current);
}
};
}, [channelId]);
useEffect(() => {
if (status !== RequestStatus.SUCCESS && errorCount! >= 2) {
setConnected(false);
} else if (status === RequestStatus.SUCCESS) {
setConnected(true);
}
}, [status, errorCount]);
useEffect(() => {
if (errorCount! > MAX_WEBSOCKET_RETRIES) {
handleWebSocket(false);
handleReconnect();
}
}, [errorCount]);
useEffect(() => {
const navbarChanged = (height: number) => {
setTranslateY(height);
};
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
return () => EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, navbarChanged);
}, [translateY]);
const animatedStyle = useAnimatedStyle(() => {
const onAnimation = (isFinished: boolean) => {
if (isFinished) {
runOnJS(hanleAnimationFinished)();
}
};
bgColor.value = withTiming(connected ? 1 : 0, {duration: 100, easing: Easing.linear});
return {
backgroundColor: interpolateColor(
bgColor.value,
[0, 1],
colors,
),
transform: [{translateY: withTiming(connected ? 0 : translateY, {duration: 300}, onAnimation)}],
};
}, [connected, translateY]);
if (netinfo.isInternetReachable) {
if (connected) {
i18nId = t('mobile.offlineIndicator.connected');
defaultMessage = 'Connected';
action = (
<View style={styles.actionContainer}>
<CompassIcon
color='#FFFFFF'
name='check'
size={20}
/>
</View>
);
} else {
i18nId = t('mobile.offlineIndicator.connecting');
defaultMessage = 'Connecting...';
action = (
<View style={styles.actionContainer}>
<ActivityIndicator
color='#FFFFFF'
size='small'
/>
</View>
);
}
} else {
i18nId = t('mobile.offlineIndicator.offline');
defaultMessage = 'No internet connection';
}
return (
<Animated.View
pointerEvents='none'
style={[styles.container, animatedStyle]}
>
<SafeAreaView
edges={['left', 'right']}
style={styles.wrapper}
>
{Boolean(i18nId) &&
<FormattedText
defaultMessage={defaultMessage}
id={i18nId}
style={styles.message}
testID='network_indicator.message'
/>
}
{action}
</SafeAreaView>
</Animated.View>
);
};
export default NetworkIndicator;