Add connection banner (#6798)

* Add connection banner

* Switch icon depending on whether it is connected or not

* Clean timeout and change text

* Handle optimistic approach

* Piggyback server item fix

* Use toMilliseconds util function

* Set the websocket as disconnected when we are manually closing it

* Do not hide banner when app state changes

Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
This commit is contained in:
Daniel Espino García
2022-12-02 15:31:21 +01:00
committed by GitHub
parent a3489d9674
commit 88835ce142
9 changed files with 255 additions and 15 deletions

View File

@@ -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<NodeJS.Timeout | null | undefined>) => {
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<NodeJS.Timeout | null>();
const openTimeout = useRef<NodeJS.Timeout | null>();
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 (
<Animated.View
style={[style.background, bannerStyle]}
>
<View
style={isConnected ? style.bannerContainerConnected : style.bannerContainerNotConnected}
>
{visible &&
<View
style={style.wrapper}
>
<Text
style={style.bannerTextContainer}
ellipsizeMode='tail'
numberOfLines={1}
>
<CompassIcon
color={theme.centerChannelBg}
name={isConnected ? 'check' : 'information-outline'}
size={18}
/>
{' '}
<Text style={style.bannerText}>
{text}
</Text>
</Text>
</View>
}
</View>
</Animated.View>
);
};
export default ConnectionBanner;

View File

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

View File

@@ -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<boolean>} = {};
private clients: Record<string, WebSocketClient> = {};
private connectionTimerIDs: Record<string, DebouncedFunc<() => 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);
}
};

View File

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

View File

@@ -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'
>
<ConnectionBanner/>
{props.isLicensed &&
<AnnouncementBanner/>
}

View File

@@ -157,7 +157,7 @@ const ServerItem = ({
const viewRef = useRef<View>(null);
const [showTutorial, setShowTutorial] = useState(false);
const [itemBounds, setItemBounds] = useState<TutorialItemBounds>({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 = ({
</Text>
)}
{Boolean(database) && server.lastActiveAt > 0 &&
{server.lastActiveAt > 0 &&
<WebSocket
database={database!}
serverUrl={server.url}
/>
}
{showTutorial &&

View File

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

View File

@@ -31,7 +31,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const WebSocket = ({isConnected}: Props) => {
const theme = useTheme();
if (!isConnected) {
if (isConnected) {
return null;
}

View File

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