diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 280842307..9ca65fdc1 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native'; import LocalConfig from '@assets/config.json'; import {Events} from '@constants'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; import test_helper from '@test/test_helper'; import * as ClientConstants from './constants'; @@ -62,7 +63,17 @@ jest.mock('@managers/performance_metrics_manager', () => ({ collectNetworkRequestData: jest.fn(), })); +jest.mock('@managers/network_performance_manager', () => ({ + __esModule: true, + default: { + startRequestTracking: jest.fn(() => 'mock-request-id-123'), + completeRequestTracking: jest.fn(), + cancelRequestTracking: jest.fn(), + }, +})); + describe('ClientTracking', () => { + const mockedNPM = jest.mocked(NetworkPerformanceManager); const apiClientMock = { baseUrl: 'https://example.com', get: jest.fn(), @@ -877,5 +888,73 @@ describe('ClientTracking', () => { expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined(); }); }); + + describe('Network Performance Tracking', () => { + const createMockMetrics = (overrides = {}) => ({ + latency: 500, + size: 1000, + compressedSize: 500, + startTime: Date.now(), + endTime: Date.now() + 500, + speedInMbps: 1, + networkType: 'Wi-Fi', + tlsCipherSuite: 'none', + tlsVersion: 'none', + isCached: false, + httpVersion: 'h2', + connectionTime: 0, + ...overrides, + }); + + const mockSuccessResponse = (metrics = createMockMetrics()) => ({ + ok: true, + data: {success: true}, + headers: {}, + metrics, + }); + + const requestOptions = { + method: 'GET', + groupLabel: 'Cold Start' as RequestGroupLabel, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call full tracking lifecycle', async () => { + const mockMetrics = createMockMetrics(); + apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await client.doFetchWithTracking('https://example.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics); + }); + + it('should pass correct server URL to NetworkPerformanceManager', async () => { + const customBaseUrl = 'https://custom-server.com'; + const customApiClient = {...apiClientMock, baseUrl: customBaseUrl}; + const customClient = new ClientTracking(customApiClient as unknown as APIClientInterface); + const mockMetrics = createMockMetrics({latency: 300, size: 2000, compressedSize: 1000, speedInMbps: 2}); + + customApiClient.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await customClient.doFetchWithTracking('https://custom-server.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'https://custom-server.com/api'); + expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'mock-request-id-123', mockMetrics); + }); + + it('should call cancelRequestTracking when request fails', async () => { + apiClientMock.get.mockRejectedValue(new Error('Request failed')); + + await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.'); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123'); + expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled(); + }); + }); }); /* eslint-enable max-lines */ diff --git a/app/client/rest/tracking.ts b/app/client/rest/tracking.ts index 1f307bca3..5d76e5169 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -7,6 +7,7 @@ import {DeviceEventEmitter, Platform} from 'react-native'; import {CollectNetworkMetrics} from '@assets/config.json'; import {Events} from '@constants'; import {setServerCredentials} from '@init/credentials'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant'; import {isErrorWithStatusCode} from '@utils/errors'; @@ -383,10 +384,13 @@ export default class ClientTracking { this.incrementRequestCount(groupLabel); } + const performanceRequestId = NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url); + let response: ClientResponse; try { response = await request!(url, this.buildRequestOptions(options)); } catch (error) { + NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, performanceRequestId); const response_error = error as ClientError; const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined; throw new ClientError(this.apiClient.baseUrl, { @@ -409,6 +413,7 @@ export default class ClientTracking { if (groupLabel && CollectNetworkMetrics) { this.trackRequest(groupLabel, url, response.metrics); } + NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, performanceRequestId, response.metrics); const serverVersion = semverFromServerVersion( headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()], ); diff --git a/app/components/connection_banner/connection_banner.test.tsx b/app/components/connection_banner/connection_banner.test.tsx new file mode 100644 index 000000000..5852776b0 --- /dev/null +++ b/app/components/connection_banner/connection_banner.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntl, screen} from '@test/intl-test-helper'; + +import ConnectionBanner from './connection_banner'; +import {useConnectionBanner} from './use_connection_banner'; + +jest.mock('./use_connection_banner'); + +jest.mock('@context/theme', () => ({ + useTheme: () => ({ + centerChannelBg: '#ffffff', + centerChannelColor: '#3d3c40', + onlineIndicator: '#06d6a0', + sidebarBg: '#2f3e4e', + }), +})); + +jest.mock('@hooks/device', () => ({ + useAppState: () => 'active', +})); + +jest.mock('@react-native-community/netinfo', () => ({ + useNetInfo: () => ({ + type: 'wifi', + isConnected: true, + isInternetReachable: true, + }), +})); + +describe('ConnectionBanner', () => { + const mockUseConnectionBanner = useConnectionBanner as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render banner content when not visible', () => { + mockUseConnectionBanner.mockReturnValue({ + visible: false, + bannerText: 'Test message', + isShowingConnectedBanner: false, + }); + + renderWithIntl( + , + ); + + expect(screen.queryByText('Test message')).toBeNull(); + }); + + it('should render disconnection banner when visible', () => { + mockUseConnectionBanner.mockReturnValue({ + visible: true, + bannerText: 'Unable to connect to network', + isShowingConnectedBanner: false, + }); + + renderWithIntl( + , + ); + + expect(screen.getByText('Unable to connect to network')).toBeTruthy(); + }); + + it('should render connection restored banner when showing connected state', () => { + mockUseConnectionBanner.mockReturnValue({ + visible: true, + bannerText: 'Connection restored', + isShowingConnectedBanner: true, + }); + + renderWithIntl( + , + ); + + expect(screen.getByText('Connection restored')).toBeTruthy(); + }); + + it('should render slow network banner', () => { + mockUseConnectionBanner.mockReturnValue({ + visible: true, + bannerText: 'Limited network connection', + isShowingConnectedBanner: false, + }); + + renderWithIntl( + , + ); + + expect(screen.getByText('Limited network connection')).toBeTruthy(); + }); + + it('should render server not reachable banner', () => { + mockUseConnectionBanner.mockReturnValue({ + visible: true, + bannerText: 'The server is not reachable', + isShowingConnectedBanner: false, + }); + + renderWithIntl( + , + ); + + expect(screen.getByText('The server is not reachable')).toBeTruthy(); + }); +}); + diff --git a/app/components/connection_banner/connection_banner.tsx b/app/components/connection_banner/connection_banner.tsx index ce4267399..aed4c706b 100644 --- a/app/components/connection_banner/connection_banner.tsx +++ b/app/components/connection_banner/connection_banner.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {useNetInfo} from '@react-native-community/netinfo'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect} from 'react'; import {useIntl} from 'react-intl'; import { Text, @@ -14,13 +14,16 @@ 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'; +import {useConnectionBanner} from './use_connection_banner'; + +import type {NetworkPerformanceState} from '@managers/network_performance_manager'; + type Props = { websocketState: WebsocketConnectedState; + networkPerformanceState: NetworkPerformanceState; } const getStyle = makeStyleSheetFromTheme((theme: Theme) => { @@ -64,87 +67,24 @@ const getStyle = makeStyleSheetFromTheme((theme: Theme) => { }; }); -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 = ({ websocketState, + networkPerformanceState, }: 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 height = useSharedValue(0); - const isConnected = websocketState === 'connected'; - - const openCallback = useCallback(() => { - setVisible(true); - clearTimeoutRef(openTimeout); - }, []); - - const closeCallback = useCallback(() => { - setVisible(false); - clearTimeoutRef(closeTimeout); - }, []); - - useEffect(() => { - if (websocketState === 'connecting') { - openCallback(); - } else if (!isConnected) { - openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN); - } - return () => { - clearTimeoutRef(openTimeout); - clearTimeoutRef(closeTimeout); - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount - - 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]); - - useDidUpdate(() => { - 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 { - setVisible(false); - clearTimeoutRef(openTimeout); - clearTimeoutRef(closeTimeout); - } - }, [appState === 'active']); + const {visible, bannerText, isShowingConnectedBanner} = useConnectionBanner({ + websocketState, + networkPerformanceState, + netInfo, + appState, + intl, + }); useEffect(() => { height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, { @@ -156,23 +96,12 @@ const ConnectionBanner = ({ height: height.value, })); - let text; - if (isConnected) { - text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}); - } else if (websocketState === 'connecting') { - text = intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'}); - } 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: 'Unable to connect to network'}); - } - return ( {visible && {' '} - {text} + {bannerText} diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts index 4b12644a5..9c7729ada 100644 --- a/app/components/connection_banner/index.ts +++ b/app/components/connection_banner/index.ts @@ -4,12 +4,14 @@ import {withObservables} from '@nozbe/watermelondb/react'; import {withServerUrl} from '@context/server'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; import WebsocketManager from '@managers/websocket_manager'; import ConnectionBanner from './connection_banner'; const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ websocketState: WebsocketManager.observeWebsocketState(serverUrl), + networkPerformanceState: NetworkPerformanceManager.observePerformanceState(serverUrl), })); export default withServerUrl(enhanced(ConnectionBanner)); diff --git a/app/components/connection_banner/use_connection_banner.test.ts b/app/components/connection_banner/use_connection_banner.test.ts new file mode 100644 index 000000000..f900d6484 --- /dev/null +++ b/app/components/connection_banner/use_connection_banner.test.ts @@ -0,0 +1,678 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook, act, waitFor} from '@testing-library/react-native'; + +import {useConnectionBanner} from './use_connection_banner'; + +import type {NetworkPerformanceState} from '@managers/network_performance_manager'; +import type {NetInfoState} from '@react-native-community/netinfo'; +import type {IntlShape} from 'react-intl'; + +const createMockIntl = (): IntlShape => ({ + formatMessage: jest.fn(({defaultMessage}) => defaultMessage || ''), + formatDate: jest.fn(), + formatTime: jest.fn(), + formatNumber: jest.fn(), + formatPlural: jest.fn(), + formatList: jest.fn(), + formatDisplayName: jest.fn(), +} as unknown as IntlShape); + +const createMockNetInfo = (isInternetReachable: boolean | null = true): NetInfoState => ({ + type: 'wifi', + isConnected: true, + isInternetReachable, + details: { + ssid: 'test-network', + bssid: null, + strength: 100, + ipAddress: '192.168.1.1', + subnet: '255.255.255.0', + frequency: 2400, + linkSpeed: 100, + rxLinkSpeed: null, + txLinkSpeed: null, + isConnectionExpensive: false, + }, +} as NetInfoState); + +describe('useConnectionBanner', () => { + let mockIntl: IntlShape; + + beforeEach(() => { + mockIntl = createMockIntl(); + }); + + describe('initial session behavior', () => { + it('should not show disconnection banner during initial session', async () => { + const {result} = renderHook(() => useConnectionBanner({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + })); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + expect(result.current.bannerText).toBe(''); + }); + }); + + it('should not show connecting banner during initial session', async () => { + const {result} = renderHook(() => useConnectionBanner({ + websocketState: 'connecting' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + })); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + }); + }); + + it('should show internet unreachable banner even during initial session', async () => { + const {result} = renderHook(() => useConnectionBanner({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + })); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('The server is not reachable'); + }); + }); + }); + + describe('after initial session (post-first-connection)', () => { + it('should show disconnection banner after initial connection is established', async () => { + const {result, rerender} = renderHook( + ({websocketState, ...rest}) => useConnectionBanner({ + websocketState, + ...rest, + }), + { + initialProps: { + websocketState: 'connecting' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + // First, establish connection (ends initial session) + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + // Should not show "Connection restored" on initial connection + await waitFor(() => { + expect(result.current.visible).toBe(false); + }); + + // Now disconnect - should show banner + act(() => { + rerender({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Unable to connect to network'); + }); + }); + + it('should show connecting banner after initial session', async () => { + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + // First connect to end initial session + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + // Disconnect + act(() => { + rerender({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + // Now go to connecting state - should show banner + act(() => { + rerender({ + websocketState: 'connecting' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Connecting...'); + }); + }); + + it('should show connection restored banner on reconnection', async () => { + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + // First connect to end initial session + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + }); + + // Disconnect + act(() => { + rerender({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Unable to connect to network'); + }); + + // Reconnect - should show "Connection restored" + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Connection restored'); + expect(result.current.isShowingConnectedBanner).toBe(true); + }); + }); + }); + + describe('slow network state', () => { + it('should show slow network banner when network is slow', async () => { + const {result} = renderHook(() => useConnectionBanner({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + })); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Limited network connection'); + }); + }); + + it('should only show slow network banner once', () => { + jest.useFakeTimers(); + + const {result, rerender} = renderHook( + ({networkPerformanceState, ...rest}) => useConnectionBanner({ + networkPerformanceState, + ...rest, + }), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + expect(result.current.visible).toBe(true); + + // Wait for auto-close + act(() => { + jest.advanceTimersByTime(2100); + }); + + expect(result.current.visible).toBe(false); + + // Go to normal then back to slow + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + // Should not show again + expect(result.current.visible).toBe(false); + + jest.useRealTimers(); + }); + }); + + describe('banner priorities', () => { + it('should prioritize internet unreachable over disconnected', async () => { + const {result} = renderHook(() => useConnectionBanner({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + })); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('The server is not reachable'); + }); + }); + + it('should not show other banners when one is already visible with timeout', async () => { + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('The server is not reachable'); + }); + + // Try to trigger slow network while banner is visible + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + }); + }); + + // Should still show internet unreachable + await waitFor(() => { + expect(result.current.bannerText).toBe('The server is not reachable'); + }); + }); + }); + + describe('app state changes', () => { + it('should hide banner when app goes to background', async () => { + const {result, rerender} = renderHook( + ({appState, ...rest}) => useConnectionBanner({ + appState, + ...rest, + }), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + + // Go to background + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'background', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + expect(result.current.bannerText).toBe(''); + }); + }); + + it('should reset slow banner flag when app goes to background', async () => { + const {result, rerender} = renderHook( + ({appState, networkPerformanceState, ...rest}) => useConnectionBanner({ + appState, + networkPerformanceState, + ...rest, + }), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + + // Go to background + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'background', + intl: mockIntl, + }); + }); + + // Come back to active + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + // Should show slow banner again (flag was reset) + await waitFor(() => { + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Limited network connection'); + }); + }); + + it('should not show connection restored banner when returning from background if websocket stayed connected', async () => { + const {result, rerender} = renderHook( + ({appState, websocketState, ...rest}) => useConnectionBanner({ + appState, + websocketState, + ...rest, + }), + { + initialProps: { + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + }); + + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'background', + intl: mockIntl, + }); + }); + + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + await waitFor(() => { + expect(result.current.visible).toBe(false); + expect(result.current.bannerText).toBe(''); + expect(result.current.isShowingConnectedBanner).toBe(false); + }); + }); + }); + + describe('auto-close behavior', () => { + it('should auto-close internet unreachable banner after 2 seconds', () => { + jest.useFakeTimers(); + + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(false), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('The server is not reachable'); + + // Internet becomes reachable again, then wait for timeout + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(true), + appState: 'active', + intl: mockIntl, + }); + jest.advanceTimersByTime(2100); + }); + + expect(result.current.visible).toBe(false); + + jest.useRealTimers(); + }); + + it('should auto-close slow network banner after 2 seconds', () => { + jest.useFakeTimers(); + + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'slow' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Limited network connection'); + + // Network becomes normal, then wait for timeout + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + jest.advanceTimersByTime(2100); + }); + + expect(result.current.visible).toBe(false); + + jest.useRealTimers(); + }); + + it('should auto-close connection restored banner after 2 seconds', () => { + jest.useFakeTimers(); + + const {result, rerender} = renderHook( + (props) => useConnectionBanner(props), + { + initialProps: { + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }, + }, + ); + + // First connect to end initial session + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + expect(result.current.visible).toBe(false); + + // Disconnect + act(() => { + rerender({ + websocketState: 'not_connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Unable to connect to network'); + + // Reconnect - should show "Connection restored" + act(() => { + rerender({ + websocketState: 'connected' as WebsocketConnectedState, + networkPerformanceState: 'normal' as NetworkPerformanceState, + netInfo: createMockNetInfo(), + appState: 'active', + intl: mockIntl, + }); + }); + + expect(result.current.visible).toBe(true); + expect(result.current.bannerText).toBe('Connection restored'); + expect(result.current.isShowingConnectedBanner).toBe(true); + + // Wait for auto-close + act(() => { + jest.advanceTimersByTime(2100); + }); + + expect(result.current.visible).toBe(false); + expect(result.current.isShowingConnectedBanner).toBe(false); + + jest.useRealTimers(); + }); + }); +}); diff --git a/app/components/connection_banner/use_connection_banner.ts b/app/components/connection_banner/use_connection_banner.ts new file mode 100644 index 000000000..6aa897dcd --- /dev/null +++ b/app/components/connection_banner/use_connection_banner.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useRef, useState} from 'react'; + +import useDidUpdate from '@hooks/did_update'; + +import type {NetworkPerformanceState} from '@managers/network_performance_manager'; +import type {NetInfoState} from '@react-native-community/netinfo'; +import type {IntlShape} from 'react-intl'; + +const CLOSE_TIMEOUT_DURATION_MS = 2000; + +const clearTimeoutRef = (ref: React.MutableRefObject) => { + if (ref.current) { + clearTimeout(ref.current); + ref.current = null; + } +}; + +type UseConnectionBannerParams = { + websocketState: WebsocketConnectedState; + networkPerformanceState: NetworkPerformanceState; + netInfo: NetInfoState; + appState: string; + intl: IntlShape; +}; + +type UseConnectionBannerReturn = { + visible: boolean; + bannerText: string; + isShowingConnectedBanner: boolean; +}; + +export const useConnectionBanner = ({ + websocketState, + networkPerformanceState, + netInfo, + appState, + intl, +}: UseConnectionBannerParams): UseConnectionBannerReturn => { + const closeTimeout = useRef(); + const openTimeout = useRef(); + const initialAppSession = useRef(true); + const previousWebsocketState = useRef(websocketState); + const hasShownSlowBanner = useRef(false); + + const [visible, setVisible] = useState(false); + const [bannerText, setBannerText] = useState(''); + const [isShowingConnectedBanner, setIsShowingConnectedBanner] = useState(false); + + const closeCallback = useCallback(() => { + setVisible(false); + clearTimeoutRef(closeTimeout); + }, []); + + const openCallback = useCallback(() => { + clearTimeoutRef(closeTimeout); + clearTimeoutRef(openTimeout); + setVisible(true); + }, []); + + const handleDisconnectedState = useCallback((): boolean => { + if (websocketState === 'not_connected') { + previousWebsocketState.current = 'not_connected'; + + if (!initialAppSession.current) { + setBannerText(intl.formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'})); + openCallback(); + return true; + } + } + return false; + }, [websocketState, openCallback, intl]); + + const handleInternetUnreachableState = useCallback((): boolean => { + if (netInfo.isInternetReachable === false) { + setBannerText(intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'})); + openCallback(); + closeTimeout.current = setTimeout(closeCallback, CLOSE_TIMEOUT_DURATION_MS); + return true; + } + return false; + }, [netInfo.isInternetReachable, intl, openCallback, closeCallback]); + + const handleSlowNetworkState = useCallback((): boolean => { + if (networkPerformanceState === 'slow' && !hasShownSlowBanner.current) { + hasShownSlowBanner.current = true; + + setBannerText(intl.formatMessage({id: 'connection_banner.slow', defaultMessage: 'Limited network connection'})); + openCallback(); + closeTimeout.current = setTimeout(() => { + closeCallback(); + }, CLOSE_TIMEOUT_DURATION_MS); + return true; + } + return false; + }, [networkPerformanceState, intl, openCallback, closeCallback]); + + const handleConnectedState = useCallback((): boolean => { + if (websocketState === 'connected' && previousWebsocketState.current !== 'connected') { + previousWebsocketState.current = 'connected'; + if (!initialAppSession.current && !isShowingConnectedBanner) { + setIsShowingConnectedBanner(true); + setBannerText(intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'})); + openCallback(); + closeTimeout.current = setTimeout(() => { + closeCallback(); + + setIsShowingConnectedBanner(false); + }, CLOSE_TIMEOUT_DURATION_MS); + return true; + } + + initialAppSession.current = false; + return true; + } + return false; + }, [websocketState, intl, openCallback, closeCallback, isShowingConnectedBanner]); + + const handleConnectingState = useCallback((): boolean => { + if (websocketState === 'connecting') { + if (!initialAppSession.current) { + setBannerText(intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'})); + openCallback(); + return true; + } + previousWebsocketState.current = 'connecting'; + } + return false; + }, [websocketState, intl, openCallback]); + + useEffect(() => { + return () => { + clearTimeoutRef(closeTimeout); + clearTimeoutRef(openTimeout); + }; + }, []); + + useEffect(() => { + if (appState !== 'active') { + return; + } + if (visible && closeTimeout.current) { + return; + } + + const priorities = () => { + if (handleInternetUnreachableState()) { + return; + } + if (handleDisconnectedState()) { + return; + } + if (handleSlowNetworkState()) { + return; + } + if (handleConnectedState()) { + return; + } + handleConnectingState(); + }; + + priorities(); + }, [ + handleInternetUnreachableState, + handleDisconnectedState, + handleSlowNetworkState, + handleConnectedState, + handleConnectingState, + visible, + appState, + ]); + + useDidUpdate(() => { + if (appState !== 'active') { + setVisible(false); + setBannerText(''); + clearTimeoutRef(openTimeout); + clearTimeoutRef(closeTimeout); + hasShownSlowBanner.current = false; + setIsShowingConnectedBanner(false); + } + }, [appState]); + + return { + visible, + bannerText, + isShowingConnectedBanner, + }; +}; + diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 6e8d1286a..1178abeeb 100644 --- a/app/managers/network_manager.ts +++ b/app/managers/network_manager.ts @@ -62,7 +62,7 @@ class NetworkManagerSingleton { waitsForConnectivity: false, httpMaximumConnectionsPerHost: 100, cancelRequestsOnUnauthorized: true, - collectMetrics: false, + collectMetrics: true, }, retryPolicyConfiguration: { type: RetryTypes.EXPONENTIAL_RETRY, @@ -139,7 +139,7 @@ class NetworkManagerSingleton { timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest, timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource, waitsForConnectivity: managedConfig?.useVPN === 'true', - collectMetrics: LocalConfig.CollectNetworkMetrics, + collectMetrics: true, }, headers, }; diff --git a/app/managers/network_performance_manager.test.ts b/app/managers/network_performance_manager.test.ts new file mode 100644 index 000000000..ec4da263f --- /dev/null +++ b/app/managers/network_performance_manager.test.ts @@ -0,0 +1,651 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {testExports} from './network_performance_manager'; + +import type {ClientResponseMetrics} from '@mattermost/react-native-network-client'; +import type {AppStateStatus} from 'react-native'; + +jest.mock('@utils/log', () => ({ + logDebug: jest.fn(), +})); + +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn(() => ({ + remove: jest.fn(), + })), + }, +})); + +const { + NetworkPerformanceManagerSingleton, + REQUEST_OUTCOME_WINDOW_SIZE, + MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + calculatePerformanceStateFromOutcomes, +} = testExports; + +const createMockMetrics = (latency: number, size: number, compressedSize: number): ClientResponseMetrics => ({ + latency, + size, + compressedSize, + startTime: Date.now(), + endTime: Date.now() + latency, + networkType: 'wifi', + tlsCipherSuite: 'TLS_AES_256_GCM_SHA384', + tlsVersion: 'TLSv1.3', + httpVersion: 'HTTP/2', + isCached: false, + connectionTime: 0, + speedInMbps: 0, +}); + +describe('Pure Functions', () => { + describe('calculatePerformanceStateFromOutcomes', () => { + describe('initial detection', () => { + it('should return normal when not enough requests for initial detection', () => { + const outcomes = [ + {timestamp: Date.now(), isSlow: true, wasEarlyDetection: false}, + {timestamp: Date.now(), isSlow: true, wasEarlyDetection: false}, + ]; + + const state = calculatePerformanceStateFromOutcomes(outcomes, true); + expect(state).toBe('normal'); + }); + + it('should return slow when initial detection threshold is met', () => { + const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 3, // 3 out of 4 = 75% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, true); + expect(state).toBe('slow'); + }); + + it('should return normal when initial detection threshold is met but percentage is low', () => { + const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 2, // 2 out of 4 = 50% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, true); + expect(state).toBe('normal'); + }); + }); + + describe('subsequent detection', () => { + it('should return slow even with fewer requests than subsequent threshold', () => { + const outcomes = Array.from({length: 6}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 5, // 5 out of 6 = 83% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('slow'); + }); + + it('should return normal when slow percentage is below threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 3, // 3 out of 10 = 30% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('normal'); + }); + + it('should return slow when slow percentage meets threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 7, // 7 out of 10 = 70% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('slow'); + }); + + it('should return slow when slow percentage exceeds threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 8, // 8 out of 10 = 80% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('slow'); + }); + }); + }); +}); + +describe('NetworkPerformanceManager', () => { + let performanceManager: InstanceType; + const serverUrl = 'https://test-server.com'; + + beforeEach(() => { + performanceManager = new NetworkPerformanceManagerSingleton(); + }); + + describe('request tracking lifecycle', () => { + it('should start and complete request tracking', () => { + performanceManager.observePerformanceState(serverUrl); + + const url = '/api/v4/users/me'; + const metrics = createMockMetrics(500, 1000, 500); + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + expect(typeof requestId).toBe('string'); + expect(requestId).toContain('-'); + + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(1); + expect(stats.slowRequests).toBe(0); + }); + + it('should track slow requests correctly', () => { + performanceManager.observePerformanceState(serverUrl); + + const url = '/api/v4/posts'; + const slowMetrics = createMockMetrics(3000, 1000, 500); + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(1); + expect(stats.slowRequests).toBe(1); + expect(stats.slowPercentage).toBe(1.0); + }); + + it('should cancel request tracking on failure', () => { + const url = '/api/v4/teams'; + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + performanceManager.cancelRequestTracking(serverUrl, requestId); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + }); + + describe('performance state calculation', () => { + it('should return normal when slow percentage is below threshold', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toContain('normal'); + }); + + it('should return slow when slow percentage meets threshold', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 7 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toContain('slow'); + }); + + it('should use initial detection threshold for first slow detection', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 out of 4 = 75% + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toEqual(['normal', 'slow']); + }); + + it('should not return to normal when switching from initial to subsequent detection', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + for (let i = 0; i < 4; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`); + const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 more slow out of 4 + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + + expect(states.filter((s) => s === 'normal')).toHaveLength(1); + expect(states).toEqual(['normal', 'slow']); + }); + }); + + describe('outcome statistics', () => { + it('should provide accurate request outcome statistics', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < 20; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 15 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(20); + expect(stats.slowRequests).toBe(15); + expect(stats.slowPercentage).toBe(0.75); + expect(stats.earlyDetectionCount).toBe(0); + }); + + it('should limit outcome window size', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE + 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE); + }); + }); + + describe('server management', () => { + it('should handle multiple servers independently', () => { + const serverUrl2 = 'https://test-server-2.com'; + + performanceManager.observePerformanceState(serverUrl); + performanceManager.observePerformanceState(serverUrl2); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl2, requestId, metrics); + } + + const state1 = performanceManager.getCurrentPerformanceState(serverUrl); + const state2 = performanceManager.getCurrentPerformanceState(serverUrl2); + + expect(state1).toBe('normal'); + expect(state2).toBe('slow'); + + performanceManager.removeServer(serverUrl2); + }); + + it('should clean up when server is removed', () => { + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + performanceManager.removeServer(serverUrl); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + + it('should not recreate storage when request completes after server removal', () => { + performanceManager.observePerformanceState(serverUrl); + + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + + performanceManager.removeServer(serverUrl); + + const metrics = createMockMetrics(3000, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + + const state = performanceManager.getCurrentPerformanceState(serverUrl); + expect(state).toBe('normal'); + }); + + it('should not recreate storage when early detection timer fires after server removal', () => { + jest.useFakeTimers(); + + performanceManager.observePerformanceState(serverUrl); + + performanceManager.startRequestTracking(serverUrl, '/api/test'); + + performanceManager.removeServer(serverUrl); + + jest.advanceTimersByTime(2000); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + + const state = performanceManager.getCurrentPerformanceState(serverUrl); + expect(state).toBe('normal'); + + jest.useRealTimers(); + }); + }); + + describe('observable behavior', () => { + it('should emit state changes when performance changes', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toEqual(['normal', 'slow']); + }); + }); + + describe('debug logging', () => { + const {logDebug} = require('@utils/log'); + + beforeEach(() => { + logDebug.mockClear(); + }); + + it('should log when performance state degrades from normal to slow', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).toHaveBeenCalledWith( + `Network performance degraded for ${serverUrl}: normal -> slow`, + expect.objectContaining({ + totalRequests: expect.any(Number), + slowRequests: expect.any(Number), + slowPercentage: expect.any(String), + earlyDetectionCount: expect.any(Number), + lastOutcome: expect.objectContaining({ + isSlow: expect.any(Boolean), + wasEarlyDetection: expect.any(Boolean), + }), + }), + ); + }); + + it('should not log when performance state remains the same', () => { + for (let i = 0; i < 3; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).not.toHaveBeenCalled(); + }); + + it('should log with correct details when performance degrades', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(3000, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).toHaveBeenCalledWith( + `Network performance degraded for ${serverUrl}: normal -> slow`, + expect.objectContaining({ + totalRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + slowRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + slowPercentage: '100.0%', + earlyDetectionCount: 0, + currentTimestamp: expect.any(Number), + detectionDelayMs: expect.any(Number), + detectionDelaySeconds: expect.any(String), + lastOutcome: { + isSlow: true, + wasEarlyDetection: false, + }, + }), + ); + }); + + it('should not log when performance recovers from slow to normal', () => { + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(3000, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + logDebug.mockClear(); + for (let i = 0; i < 20; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/fast-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).not.toHaveBeenCalled(); + }); + }); + + describe('early detection', () => { + it('should detect slow request early when timer fires before completion', () => { + jest.useFakeTimers(); + + performanceManager.observePerformanceState(serverUrl); + + // Start requests that will be slow + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + performanceManager.startRequestTracking(serverUrl, `/api/slow-${i}`); + } + + // Advance time past the slow request threshold + jest.advanceTimersByTime(2000); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION); + expect(stats.slowRequests).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION); + expect(stats.earlyDetectionCount).toBe(MINIMUM_REQUESTS_FOR_INITIAL_DETECTION); + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0); + + jest.useRealTimers(); + }); + + it('should not record outcome when completing request that was already early detected', () => { + jest.useFakeTimers(); + + performanceManager.observePerformanceState(serverUrl); + + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + + // Advance time to trigger early detection + jest.advanceTimersByTime(2000); + + // Now complete the request (simulate it finally finishing) + const metrics = createMockMetrics(2500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + // Should still only have 1 outcome (from early detection, not from completion) + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(1); + expect(stats.earlyDetectionCount).toBe(1); + + jest.useRealTimers(); + }); + + it('should handle early detection in performance state calculation', () => { + jest.useFakeTimers(); + + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + // Start enough slow requests to trigger initial detection + for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { + performanceManager.startRequestTracking(serverUrl, `/api/early-${i}`); + } + + // Trigger early detection + jest.advanceTimersByTime(2000); + + expect(states).toContain('slow'); + + subscription.unsubscribe(); + jest.useRealTimers(); + }); + }); + + describe('metrics handling', () => { + it('should not record outcome when metrics is undefined', () => { + performanceManager.observePerformanceState(serverUrl); + + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + performanceManager.completeRequestTracking(serverUrl, requestId, undefined); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + + it('should not record outcome when metrics.latency is undefined', () => { + performanceManager.observePerformanceState(serverUrl); + + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + const metricsWithoutLatency = { + ...createMockMetrics(500, 1000, 500), + latency: undefined as unknown as number, + }; + + performanceManager.completeRequestTracking(serverUrl, requestId, metricsWithoutLatency); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + }); + + describe('destroy method', () => { + it('should clean up multiple servers with active requests', () => { + const serverUrl2 = 'https://test-server-2.com'; + + performanceManager.observePerformanceState(serverUrl); + performanceManager.observePerformanceState(serverUrl2); + + performanceManager.startRequestTracking(serverUrl, '/api/test1'); + performanceManager.startRequestTracking(serverUrl2, '/api/test2'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + expect(performanceManager.getActiveRequestCount(serverUrl2)).toBe(1); + + performanceManager.destroy(); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0); + expect(performanceManager.getActiveRequestCount(serverUrl2)).toBe(0); + }); + }); + + describe('AppState monitoring', () => { + const {AppState} = require('react-native'); + let appStateHandler: (nextAppState: AppStateStatus) => void; + + beforeEach(() => { + AppState.addEventListener.mockImplementation((event: string, handler: (nextAppState: AppStateStatus) => void) => { + if (event === 'change') { + appStateHandler = handler; + } + return {remove: jest.fn()}; + }); + }); + + it('should setup AppState monitoring on construction', () => { + const manager = new NetworkPerformanceManagerSingleton(); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + manager.destroy(); + }); + + it('should clean up active requests when app goes to background', () => { + performanceManager.startRequestTracking(serverUrl, '/api/test'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + appStateHandler('background'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0); + }); + + it('should reset performance state to normal when app becomes inactive', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + states.push(state); + }); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('slow'); + + appStateHandler('inactive'); + + expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('normal'); + subscription.unsubscribe(); + }); + + it('should not clean up when app state is active', () => { + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + appStateHandler('active'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + performanceManager.cancelRequestTracking(serverUrl, requestId); + }); + + it('should clean up AppState subscription on destroy', () => { + const mockRemove = jest.fn(); + AppState.addEventListener.mockReturnValue({remove: mockRemove}); + + const manager = new NetworkPerformanceManagerSingleton(); + manager.destroy(); + + expect(mockRemove).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/managers/network_performance_manager.ts b/app/managers/network_performance_manager.ts new file mode 100644 index 000000000..b3bc11311 --- /dev/null +++ b/app/managers/network_performance_manager.ts @@ -0,0 +1,330 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native'; +import {BehaviorSubject} from 'rxjs'; +import {distinctUntilChanged} from 'rxjs/operators'; + +import {logDebug} from '@utils/log'; + +import type {ClientResponseMetrics} from '@mattermost/react-native-network-client'; + +export type NetworkPerformanceState = 'normal' | 'slow'; + +interface ActiveRequest { + id: string; + url: string; + startTime: number; + checkTimer?: NodeJS.Timeout; +} + +interface RequestOutcome { + timestamp: number; + isSlow: boolean; + wasEarlyDetection: boolean; +} + +const SLOW_REQUEST_THRESHOLD = 2000; +const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7; + +// We use a count-based sliding window instead of time-based filtering to prevent +// flip-flopping between states. With the 70% threshold and 10 minimum requests, +// the network needs 7/10 new requests to be fast/slow to change state, regardless +// of how old previous requests are. This provides stable state transitions. +const REQUEST_OUTCOME_WINDOW_SIZE = 20; +const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4; +const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10; + +const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean): NetworkPerformanceState => { + const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; + + if (isInitialDetection && outcomes.length < minimumRequests) { + return 'normal'; + } + + const slowRequestCount = outcomes.filter((outcome) => outcome.isSlow).length; + const slowPercentage = slowRequestCount / outcomes.length; + + return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal'; +}; + +class NetworkPerformanceManagerSingleton { + private performanceSubjects: Record> = {}; + private activeRequests: Record> = {}; + private requestOutcomes: Record = {}; + private initialRequestTimestamp: Record = {}; + private isInitialDetection: Record = {}; + private appStateSubscription: NativeEventSubscription | null = null; + + constructor() { + this.setupAppStateMonitoring(); + } + + /** + * Starts tracking a request for early performance detection. + * Returns a unique request ID that should be used when the request completes. + */ + public startRequestTracking = (serverUrl: string, url: string): string => { + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + if (!this.activeRequests[serverUrl]) { + this.activeRequests[serverUrl] = {}; + } + + const request: ActiveRequest = { + id: requestId, + url, + startTime: Date.now(), + }; + + request.checkTimer = setTimeout(() => { + this.checkRequestLatency(serverUrl, requestId); + }, SLOW_REQUEST_THRESHOLD); + + this.activeRequests[serverUrl][requestId] = request; + return requestId; + }; + + /** + * Completes request tracking and adds metrics for performance reporting. + * Should be called when the request finishes with the ID from startRequestTracking. + */ + public completeRequestTracking = (serverUrl: string, requestId: string, metrics: ClientResponseMetrics | undefined) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + const wasEarlyDetected = !activeRequest; // If not found, it was already early detected and removed + + this.clearActiveRequest(serverUrl, requestId); + + // Only record the outcome if it wasn't already recorded by early detection + if (!wasEarlyDetected && metrics?.latency) { + this.recordRequestOutcome(serverUrl, { + timestamp: Date.now(), + isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD, + wasEarlyDetection: false, + }); + } + }; + + /** + * Cancels request tracking when a request fails. + * Should be called when the request fails with the ID from startRequestTracking. + */ + public cancelRequestTracking = (serverUrl: string, requestId: string) => { + this.clearActiveRequest(serverUrl, requestId); + }; + + /** + * Returns an observable that emits network performance state changes. + * Emits 'normal' or 'slow' based on current performance metrics. + */ + public observePerformanceState = (serverUrl: string) => { + return this.getPerformanceSubject(serverUrl).asObservable().pipe( + distinctUntilChanged(), + ); + }; + + /** + * Gets the current network performance state for a server. + */ + public getCurrentPerformanceState = (serverUrl: string): NetworkPerformanceState => { + return this.getPerformanceSubject(serverUrl).getValue(); + }; + + /** + * Gets the current request outcome statistics for a server. + */ + public getRequestOutcomeStats = (serverUrl: string) => { + const outcomes = this.requestOutcomes[serverUrl] || []; + if (!outcomes.length) { + return { + totalRequests: 0, + slowRequests: 0, + slowPercentage: 0, + earlyDetectionCount: 0, + }; + } + + const slowRequests = outcomes.filter((outcome) => outcome.isSlow).length; + const earlyDetectionCount = outcomes.filter((outcome) => outcome.wasEarlyDetection).length; + + return { + totalRequests: outcomes.length, + slowRequests, + slowPercentage: slowRequests / outcomes.length, + earlyDetectionCount, + }; + }; + + /** + * Gets the count of active requests for a server (for testing purposes). + */ + public getActiveRequestCount = (serverUrl: string): number => { + return Object.keys(this.activeRequests[serverUrl] || {}).length; + }; + + /** + * Removes all data and subscriptions for a server. + */ + public removeServer = (serverUrl: string) => { + this.clearAllActiveRequests(serverUrl); + if (this.performanceSubjects[serverUrl]) { + this.performanceSubjects[serverUrl].complete(); + delete this.performanceSubjects[serverUrl]; + } + delete this.requestOutcomes[serverUrl]; + delete this.initialRequestTimestamp[serverUrl]; + delete this.isInitialDetection[serverUrl]; + }; + + /** + * Cleans up all resources when the manager is being destroyed. + */ + public destroy = () => { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + + Object.keys(this.activeRequests).forEach((serverUrl) => { + this.clearAllActiveRequests(serverUrl); + }); + + Object.keys(this.performanceSubjects).forEach((serverUrl) => { + this.performanceSubjects[serverUrl].complete(); + }); + + this.performanceSubjects = {}; + this.requestOutcomes = {}; + this.initialRequestTimestamp = {}; + this.isInitialDetection = {}; + }; + + private checkRequestLatency = (serverUrl: string, requestId: string) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + if (!activeRequest) { + return; + } + + const currentTime = Date.now(); + const elapsedTime = currentTime - activeRequest.startTime; + + if (elapsedTime >= SLOW_REQUEST_THRESHOLD) { + this.recordRequestOutcome(serverUrl, { + timestamp: currentTime, + isSlow: true, + wasEarlyDetection: true, + }); + + this.clearActiveRequest(serverUrl, requestId); + } + }; + + private clearActiveRequest = (serverUrl: string, requestId: string) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + if (activeRequest?.checkTimer) { + clearTimeout(activeRequest.checkTimer); + } + if (this.activeRequests[serverUrl]) { + delete this.activeRequests[serverUrl][requestId]; + } + }; + + private clearAllActiveRequests = (serverUrl: string) => { + const requests = this.activeRequests[serverUrl]; + if (requests) { + Object.values(requests).forEach((request) => { + if (request.checkTimer) { + clearTimeout(request.checkTimer); + } + }); + delete this.activeRequests[serverUrl]; + } + }; + + private recordRequestOutcome = (serverUrl: string, outcome: RequestOutcome) => { + if (!this.performanceSubjects[serverUrl]) { + return; + } + + if (!this.requestOutcomes[serverUrl]) { + this.requestOutcomes[serverUrl] = []; + } + + if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) { + this.initialRequestTimestamp[serverUrl] = outcome.timestamp; + } + + this.requestOutcomes[serverUrl].push(outcome); + + if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) { + this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE); + } + + const outcomes = this.requestOutcomes[serverUrl]; + const currentPerformanceState = this.getCurrentPerformanceState(serverUrl); + const isInitialDetection = !this.isInitialDetection[serverUrl]; + const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection); + + if (currentPerformanceState !== newPerformanceState && newPerformanceState === 'slow') { + const stats = this.getRequestOutcomeStats(serverUrl); + const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl]; + + logDebug(`Network performance degraded for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, { + totalRequests: stats.totalRequests, + slowRequests: stats.slowRequests, + slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`, + earlyDetectionCount: stats.earlyDetectionCount, + currentTimestamp: Date.now(), + detectionDelayMs, + detectionDelaySeconds: `${(detectionDelayMs / 1000).toFixed(1)}s`, + lastOutcome: { + isSlow: outcome.isSlow, + wasEarlyDetection: outcome.wasEarlyDetection, + }, + }); + + this.isInitialDetection[serverUrl] = true; + delete this.initialRequestTimestamp[serverUrl]; + } + + this.getPerformanceSubject(serverUrl).next(newPerformanceState); + }; + + private getPerformanceSubject = (serverUrl: string) => { + if (!this.performanceSubjects[serverUrl]) { + this.performanceSubjects[serverUrl] = new BehaviorSubject('normal'); + } + return this.performanceSubjects[serverUrl]; + }; + + private setupAppStateMonitoring = () => { + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); + }; + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState !== 'active') { + this.cleanupOnAppStateChange(); + } + }; + + private cleanupOnAppStateChange = () => { + Object.keys(this.activeRequests).forEach((serverUrl) => { + this.clearAllActiveRequests(serverUrl); + }); + + Object.keys(this.performanceSubjects).forEach((serverUrl) => { + this.performanceSubjects[serverUrl].next('normal'); + }); + }; +} + +export const testExports = { + NetworkPerformanceManagerSingleton, + SLOW_REQUEST_THRESHOLD, + REQUEST_OUTCOME_WINDOW_SIZE, + MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION, + calculatePerformanceStateFromOutcomes, +}; + +const NetworkPerformanceManager = new NetworkPerformanceManagerSingleton(); +export default NetworkPerformanceManager; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 8a3620d65..050189ee2 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -274,6 +274,7 @@ "connection_banner.connecting": "Connecting...", "connection_banner.not_connected": "Unable to connect to network", "connection_banner.not_reachable": "The server is not reachable", + "connection_banner.slow": "Limited network connection", "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...",