diff --git a/app/components/connection_banner/connection_banner.tsx b/app/components/connection_banner/connection_banner.tsx new file mode 100644 index 0000000000..89792f493a --- /dev/null +++ b/app/components/connection_banner/connection_banner.tsx @@ -0,0 +1,200 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useNetInfo} from '@react-native-community/netinfo'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import { + Text, + View, +} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import CompassIcon from '@components/compass_icon'; +import {ANNOUNCEMENT_BAR_HEIGHT} from '@constants/view'; +import {useTheme} from '@context/theme'; +import {useAppState} from '@hooks/device'; +import useDidUpdate from '@hooks/did_update'; +import {toMilliseconds} from '@utils/datetime'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + isConnected: boolean; +} + +const getStyle = makeStyleSheetFromTheme((theme: Theme) => { + const bannerContainer = { + flex: 1, + paddingHorizontal: 10, + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 8, + borderRadius: 7, + }; + return { + background: { + backgroundColor: theme.sidebarBg, + }, + bannerContainerNotConnected: { + ...bannerContainer, + backgroundColor: theme.centerChannelColor, + }, + bannerContainerConnected: { + ...bannerContainer, + backgroundColor: theme.onlineIndicator, + }, + wrapper: { + flexDirection: 'row', + flex: 1, + overflow: 'hidden', + }, + bannerTextContainer: { + flex: 1, + flexGrow: 1, + marginRight: 5, + textAlign: 'center', + color: theme.centerChannelBg, + }, + bannerText: { + ...typography('Body', 100, 'SemiBold'), + }, + }; +}); + +const clearTimeoutRef = (ref: React.MutableRefObject) => { + if (ref.current) { + clearTimeout(ref.current); + ref.current = null; + } +}; + +const TIME_TO_OPEN = toMilliseconds({seconds: 3}); +const TIME_TO_CLOSE = toMilliseconds({seconds: 1}); + +const ConnectionBanner = ({ + isConnected, +}: Props) => { + const intl = useIntl(); + const closeTimeout = useRef(); + const openTimeout = useRef(); + const height = useSharedValue(0); + const theme = useTheme(); + const [visible, setVisible] = useState(false); + const style = getStyle(theme); + const appState = useAppState(); + const netInfo = useNetInfo(); + + const openCallback = useCallback(() => { + setVisible(true); + clearTimeoutRef(openTimeout); + }, []); + + const closeCallback = useCallback(() => { + setVisible(false); + clearTimeoutRef(closeTimeout); + }, []); + + useEffect(() => { + if (!isConnected) { + openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN); + } + return () => { + clearTimeoutRef(openTimeout); + }; + }, []); + + useDidUpdate(() => { + if (isConnected) { + if (visible) { + if (!closeTimeout.current) { + closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE); + } + } else { + clearTimeoutRef(openTimeout); + } + } else if (visible) { + clearTimeoutRef(closeTimeout); + } else if (appState === 'active') { + setVisible(true); + } + }, [isConnected]); + + useEffect(() => { + if (appState === 'active') { + if (!isConnected && !visible) { + if (!openTimeout.current) { + openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN); + } + } + if (isConnected && visible) { + if (!closeTimeout.current) { + closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE); + } + } + } else { + clearTimeoutRef(openTimeout); + clearTimeoutRef(closeTimeout); + } + }, [appState]); + + useEffect(() => { + height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, { + duration: 200, + }); + }, [visible]); + + useEffect(() => { + return () => { + clearTimeoutRef(closeTimeout); + }; + }); + + const bannerStyle = useAnimatedStyle(() => ({ + height: height.value, + })); + + let text; + if (isConnected) { + text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}); + } else if (netInfo.isInternetReachable) { + text = intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'}); + } else { + text = intl.formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'No internet connection'}); + } + + return ( + + + {visible && + + + + {' '} + + {text} + + + + } + + + ); +}; + +export default ConnectionBanner; diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts new file mode 100644 index 0000000000..0b5547837b --- /dev/null +++ b/app/components/connection_banner/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import {withServerUrl} from '@context/server'; +import websocket_manager from '@managers/websocket_manager'; + +import ConnectionBanner from './connection_banner'; + +const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ + isConnected: websocket_manager.observeConnected(serverUrl), +})); + +export default withServerUrl(enhanced(ConnectionBanner)); diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index 9dc12a0889..809ad34444 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -5,6 +5,8 @@ import NetInfo, {NetInfoState} from '@react-native-community/netinfo'; import {debounce, DebouncedFunc} from 'lodash'; import {AppState, AppStateStatus} from 'react-native'; import BackgroundTimer from 'react-native-background-timer'; +import {BehaviorSubject} from 'rxjs'; +import {distinctUntilChanged} from 'rxjs/operators'; import {setCurrentUserStatusOffline} from '@actions/local/user'; import {fetchStatusByIds} from '@actions/remote/user'; @@ -22,6 +24,8 @@ const WAIT_TO_CLOSE = toMilliseconds({seconds: 15}); const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 20}); class WebsocketManager { + private connectedSubjects: {[serverUrl: string]: BehaviorSubject} = {}; + private clients: Record = {}; private connectionTimerIDs: Record void>> = {}; private isBackgroundTimerRunning = false; @@ -64,6 +68,9 @@ class WebsocketManager { this.connectionTimerIDs[serverUrl].cancel(); } delete this.clients[serverUrl]; + + this.getConnectedSubject(serverUrl).next(false); + delete this.connectedSubjects[serverUrl]; }; public createClient = (serverUrl: string, bearerToken: string, storedLastDisconnect = 0) => { @@ -85,9 +92,11 @@ class WebsocketManager { }; public closeAll = () => { - for (const client of Object.values(this.clients)) { + for (const url of Object.keys(this.clients)) { + const client = this.clients[url]; if (client.isConnected()) { client.close(true); + this.getConnectedSubject(url).next(false); } } }; @@ -109,6 +118,20 @@ class WebsocketManager { return this.clients[serverUrl]?.isConnected(); }; + public observeConnected = (serverUrl: string) => { + return this.getConnectedSubject(serverUrl).asObservable().pipe( + distinctUntilChanged(), + ); + }; + + private getConnectedSubject = (serverUrl: string) => { + if (!this.connectedSubjects[serverUrl]) { + this.connectedSubjects[serverUrl] = new BehaviorSubject(this.isConnected(serverUrl)); + } + + return this.connectedSubjects[serverUrl]; + }; + private cancelAllConnections = () => { for (const url in this.connectionTimerIDs) { if (this.connectionTimerIDs[url]) { @@ -130,11 +153,13 @@ class WebsocketManager { private onFirstConnect = (serverUrl: string) => { this.startPeriodicStatusUpdates(serverUrl); handleFirstConnect(serverUrl); + this.getConnectedSubject(serverUrl).next(true); }; private onReconnect = (serverUrl: string) => { this.startPeriodicStatusUpdates(serverUrl); handleReconnect(serverUrl); + this.getConnectedSubject(serverUrl).next(true); }; private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => { @@ -143,6 +168,7 @@ class WebsocketManager { await handleClose(serverUrl, lastDisconnect); this.stopPeriodicStatusUpdates(serverUrl); + this.getConnectedSubject(serverUrl).next(false); } }; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index a47bd5f6a1..0155f1d553 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -277,7 +277,7 @@ export const getWebSocketLastDisconnected = async (serverDatabase: Database) => } }; -export const observeWebsocket = (database: Database) => { +export const observeWebsocketLastDisconnected = (database: Database) => { return querySystemValue(database, SYSTEM_IDENTIFIERS.WEBSOCKET).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$({value: '0'}))), switchMap((model) => of$(parseInt(model.value || 0, 10) || 0)), diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index f1cbf5d63d..c547cf9a37 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -10,6 +10,7 @@ import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import AnnouncementBanner from '@components/announcement_banner'; +import ConnectionBanner from '@components/connection_banner'; import FreezeScreen from '@components/freeze_screen'; import TeamSidebar from '@components/team_sidebar'; import {Navigation as NavigationConstants, Screens} from '@constants'; @@ -166,6 +167,7 @@ const ChannelListScreen = (props: ChannelProps) => { edges={edges} testID='channel_list.screen' > + {props.isLicensed && } diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index f8301f0b00..e361ed8222 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -157,7 +157,7 @@ const ServerItem = ({ const viewRef = useRef(null); const [showTutorial, setShowTutorial] = useState(false); const [itemBounds, setItemBounds] = useState({startX: 0, startY: 0, endX: 0, endY: 0}); - const database = DatabaseManager.serverDatabases[server.url]?.database; + let displayName = server.displayName; if (server.url === server.displayName) { @@ -451,9 +451,9 @@ const ServerItem = ({ )} - {Boolean(database) && server.lastActiveAt > 0 && + {server.lastActiveAt > 0 && } {showTutorial && diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/websocket/index.ts b/app/screens/home/channel_list/servers/servers_list/server_item/websocket/index.ts index 8356dcf2b3..de3f977534 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/websocket/index.ts +++ b/app/screens/home/channel_list/servers/servers_list/server_item/websocket/index.ts @@ -2,19 +2,13 @@ // See LICENSE.txt for license information. import withObservables from '@nozbe/with-observables'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; -import {observeWebsocket} from '@queries/servers/system'; +import WebsocketManager from '@managers/websocket_manager'; import WebSocket from './websocket'; -import type {WithDatabaseArgs} from '@typings/database/database'; - -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - isConnected: observeWebsocket(database).pipe( - switchMap((value) => of$(value > 0)), - ), +const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ + isConnected: WebsocketManager.observeConnected(serverUrl), })); export default enhanced(WebSocket); diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/websocket/websocket.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/websocket/websocket.tsx index e7b5de19e3..19cac50798 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/websocket/websocket.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/websocket/websocket.tsx @@ -31,7 +31,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const WebSocket = ({isConnected}: Props) => { const theme = useTheme(); - if (!isConnected) { + if (isConnected) { return null; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1323443cae..8eba07a3ef 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -202,6 +202,9 @@ "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", "combined_system_message.you": "You", + "connection_banner.connected": "Connection restored", + "connection_banner.not_connected": "No internet connection", + "connection_banner.not_reachable": "The server is not reachable", "create_direct_message.title": "Create Direct Message", "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", "create_post.thread_reply": "Reply to this thread...",