// 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; 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(); const retryTimeout = useRef(); 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); } }); const listener = AppState.addEventListener('change', handleAppStateChange); return () => { listener.remove(); if (clearNotificationTimeout.current && AppState.currentState !== 'active') { clearTimeout(clearNotificationTimeout.current); } }; }, [netinfo.isInternetReachable, channelId]); useEffect(() => { if (channelId) { clearNotificationTimeout.current = setTimeout(() => { PushNotifications.clearChannelNotifications(channelId); }, 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 = ( ); } else { i18nId = t('mobile.offlineIndicator.connecting'); defaultMessage = 'Connecting...'; action = ( ); } } else { i18nId = t('mobile.offlineIndicator.offline'); defaultMessage = 'No internet connection'; } return ( {Boolean(i18nId) && } {action} ); }; export default NetworkIndicator;