diff --git a/app/actions/app/global.test.ts b/app/actions/app/global.test.ts index 07c4edf0e..911c2e37a 100644 --- a/app/actions/app/global.test.ts +++ b/app/actions/app/global.test.ts @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import {Tutorial} from '@constants'; -import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import { getDeviceToken, @@ -34,7 +33,6 @@ import { removePushDisabledInServerAcknowledged, storeScheduledPostTutorial, storeScheduledPostsListTutorial, - storeLowConnectivityMonitor, } from './global'; const serverUrl = 'server.test.com'; @@ -205,17 +203,4 @@ describe('/app/actions/app/global', () => { records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch(); expect(records?.[0]?.value).toBe(true); }); - - test('storeLowConnectivityMonitor', async () => { - let records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch(); - expect(records?.[0]?.value).toBeUndefined(); - - await storeLowConnectivityMonitor(true); - records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch(); - expect(records?.[0]?.value).toBe(true); - - await storeLowConnectivityMonitor(false); - records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch(); - expect(records?.[0]?.value).toBe(false); - }); }); diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index 26232f67b..8f47d6466 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -97,7 +97,3 @@ export const storePushDisabledInServerAcknowledged = async (serverUrl: string) = export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => { return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false); }; - -export const storeLowConnectivityMonitor = async (enabled: boolean) => { - return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false); -}; diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index f256b9966..6fcfe01a0 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -13,7 +13,7 @@ import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, type Prep import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team'; import {getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; -import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlaysWithExceptions, goToScreen} from '@screens/navigation'; +import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlays, goToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {isTablet} from '@utils/helpers'; @@ -95,7 +95,7 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo if (isFromNotification) { if (currentThreadId && currentThreadId === rootId && NavigationStore.getScreensInStack().includes(Screens.THREAD)) { await dismissAllModals(); - await dismissAllOverlaysWithExceptions(); + await dismissAllOverlays(); return {}; } diff --git a/app/actions/remote/entry/common.test.ts b/app/actions/remote/entry/common.test.ts index 83f0c8108..7d9361832 100644 --- a/app/actions/remote/entry/common.test.ts +++ b/app/actions/remote/entry/common.test.ts @@ -31,13 +31,7 @@ jest.mock('@actions/remote/user'); jest.mock('@actions/remote/post'); jest.mock('@actions/remote/groups'); jest.mock('@actions/remote/thread'); -jest.mock('@queries/app/global', () => { - const {of: mockOf} = require('rxjs'); - return { - getDeviceToken: jest.fn(), - observeLowConnectivityMonitor: jest.fn(() => mockOf(true)), - }; -}); +jest.mock('@queries/app/global'); jest.mock('@queries/servers/system'); jest.mock('@queries/servers/entry'); jest.mock('@queries/servers/system', () => { diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 36a84cf66..280842307 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -6,8 +6,6 @@ import {DeviceEventEmitter} from 'react-native'; import LocalConfig from '@assets/config.json'; import {Events} from '@constants'; -import NetworkPerformanceManager from '@managers/network_performance_manager'; -import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import test_helper from '@test/test_helper'; import * as ClientConstants from './constants'; @@ -64,18 +62,7 @@ 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 mockedPMM = jest.mocked(PerformanceMetricsManager); const apiClientMock = { baseUrl: 'https://example.com', get: jest.fn(), @@ -890,109 +877,5 @@ 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, - }; - - afterEach(() => { - 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 not call completeRequestTracking when response.metrics is undefined', async () => { - apiClientMock.get.mockResolvedValue({ - ok: true, - data: {success: true}, - headers: {}, - }); - - await client.doFetchWithTracking('https://example.com/api', requestOptions); - - expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); - expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled(); - }); - - it('should call tracking methods independently of CollectNetworkMetrics', async () => { - LocalConfig.CollectNetworkMetrics = false; - const mockMetrics = createMockMetrics(); - apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); - - await client.doFetchWithTracking('https://example.com/api', requestOptions); - - expect(mockedPMM.collectNetworkRequestData).not.toHaveBeenCalled(); - 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 call tracking methods when both flags are enabled', async () => { - LocalConfig.CollectNetworkMetrics = true; - 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 af61ddc95..1f307bca3 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -7,7 +7,6 @@ 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'; @@ -132,24 +131,6 @@ export default class ClientTracking { group.totalCompressedSize += metrics?.compressedSize ?? 0; } - startNetworkPerformanceTracking(url: string): string | undefined { - return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url); - } - - completeNetworkPerformanceTracking(requestId: string | undefined, url: string, metrics: ClientResponseMetrics | undefined) { - if (!requestId || !metrics) { - return; - } - NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics); - } - - cancelNetworkPerformanceTracking(requestId: string | undefined) { - if (!requestId) { - return; - } - NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId); - } - getAverageLatency(groupLabel: RequestGroupLabel): number { const groupData = this.requestGroups.get(groupLabel); if (!groupData) { @@ -402,13 +383,10 @@ export default class ClientTracking { this.incrementRequestCount(groupLabel); } - const performanceRequestId = this.startNetworkPerformanceTracking(url); - let response: ClientResponse; try { response = await request!(url, this.buildRequestOptions(options)); } catch (error) { - this.cancelNetworkPerformanceTracking(performanceRequestId); const response_error = error as ClientError; const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined; throw new ClientError(this.apiClient.baseUrl, { @@ -431,7 +409,6 @@ export default class ClientTracking { if (groupLabel && CollectNetworkMetrics) { this.trackRequest(groupLabel, url, response.metrics); } - this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics); const serverVersion = semverFromServerVersion( headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()], ); @@ -458,6 +435,7 @@ export default class ClientTracking { server_error_id: response.data?.id as string, status_code: response.code, url, + headers, }); }; } diff --git a/app/components/banner/Banner.test.tsx b/app/components/banner/Banner.test.tsx deleted file mode 100644 index 16b7d0914..000000000 --- a/app/components/banner/Banner.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render, screen} from '@testing-library/react-native'; -import React from 'react'; -import {Text} from 'react-native'; - -import Banner from './Banner'; -import {useBanner} from './hooks/useBanner'; - -jest.mock('./hooks/useBanner'); - -jest.mock('react-native-reanimated', () => { - const {View} = require('react-native'); - return { - __esModule: true, - default: {View}, - }; -}); - -jest.mock('react-native-gesture-handler', () => { - const ReactLib = require('react'); - return { - GestureDetector: ({children}: {children: React.ReactNode}) => { - return ReactLib.createElement('View', {testID: 'gesture-detector'}, children); - }, - }; -}); - -describe('Banner', () => { - const mockUseBanner = jest.mocked(useBanner); - - beforeEach(() => { - jest.clearAllMocks(); - mockUseBanner.mockReturnValue({ - animatedStyle: { - opacity: 1, - transform: [{translateY: 0}, {translateX: 0}], - }, - swipeGesture: {} as unknown as ReturnType['swipeGesture'], - }); - }); - - it('renders children and animated view', () => { - render( - - {'Test Content'} - , - ); - - expect(screen.getByTestId('banner-content')).toBeTruthy(); - expect(screen.getByTestId('banner-animated-view')).toBeTruthy(); - }); - - it('does not use GestureDetector when not dismissible', () => { - render( - - {'Test Content'} - , - ); - - expect(screen.queryByTestId('gesture-detector')).toBeNull(); - expect(screen.getByTestId('banner-content')).toBeTruthy(); - }); - - it('uses GestureDetector when dismissible', () => { - render( - - {'Test Content'} - , - ); - - expect(screen.getByTestId('gesture-detector')).toBeTruthy(); - expect(screen.getByTestId('banner-content')).toBeTruthy(); - }); - - it('passes correct props to useBanner hook', () => { - const onDismiss = jest.fn(); - - render( - - {'Test Content'} - , - ); - - expect(mockUseBanner).toHaveBeenCalledWith({ - animationDuration: 300, - dismissible: true, - swipeThreshold: 150, - onDismiss, - }); - }); - - it('uses default prop values when not provided', () => { - render( - - {'Test Content'} - , - ); - - expect(mockUseBanner).toHaveBeenCalledWith({ - animationDuration: 250, - dismissible: false, - swipeThreshold: 100, - onDismiss: undefined, - }); - }); -}); diff --git a/app/components/banner/Banner.tsx b/app/components/banner/Banner.tsx deleted file mode 100644 index fd4ea0eb6..000000000 --- a/app/components/banner/Banner.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {StyleSheet} from 'react-native'; -import {GestureDetector} from 'react-native-gesture-handler'; -import Animated from 'react-native-reanimated'; - -import {useBanner} from './hooks/useBanner'; - -import type {BannerProps} from './types'; - -export type {BannerProps}; - -const styles = StyleSheet.create({ - wrapper: { - width: '100%', - gap: 8, - }, -}); - -/** - * Banner - A flexible banner component - * - * Renders content with fade and slide animations. Supports swipe-to-dismiss functionality. - * - * @example - * ```tsx - * console.log('Banner dismissed!')} - * > - * Your banner content - * - * ``` - */ -const Banner: React.FC = ({ - children, - animationDuration = 250, - style, - dismissible = false, - onDismiss, - swipeThreshold = 100, -}) => { - const {animatedStyle, swipeGesture} = useBanner({ - animationDuration, - dismissible, - swipeThreshold, - onDismiss, - }); - - const bannerContent = ( - - {children} - - ); - - if (dismissible) { - return ( - - {bannerContent} - - ); - } - - return bannerContent; -}; - -export default Banner; diff --git a/app/components/banner/banner_item.test.tsx b/app/components/banner/banner_item.test.tsx deleted file mode 100644 index dbe1d8755..000000000 --- a/app/components/banner/banner_item.test.tsx +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render, fireEvent, screen} from '@testing-library/react-native'; -import React from 'react'; - -import BannerItem, {type BannerItemConfig} from './banner_item'; - -/** - * BRANCH COVERAGE NOTE: This test suite achieves 85.71% branch coverage (30/35 branches). - * - * The remaining 5 uncovered branches are architecturally unreachable due to defensive - * programming patterns and React Native's internal behavior: - * - * 1. Line 131 - `if (onDismiss)` false branch: The handleDismiss function is only - * called when the dismiss button is pressed, but the dismiss button only renders - * when onDismiss exists. It's impossible to call handleDismiss with falsy onDismiss. - * - * 2. Line 142 - `pressed && (banner.onPress || onPress) && styles.pressed`: When - * pressed=true but no handlers exist, the component is disabled=true, so React - * Native prevents the pressed state entirely. - * - * 3. Line 168 - `pressed && styles.dismissPressed`: Similar to above - when the - * dismiss button exists, it's always pressable, so pressed state is controlled - * by React Native's internal logic. - * - * COULD WE MOCK THESE SITUATIONS? - * While technically possible through complex mocking (e.g., mocking React Native's Pressable - * to force pressed=true on disabled components), doing so would: - * - Test artificial scenarios that can never occur in production - * - Reduce test reliability by testing framework internals rather than component behavior - * - Create brittle tests that break when React Native updates its internal logic - * - Violate the principle of testing realistic user interactions - * - * These uncovered branches represent defensive code that prevents runtime errors - * but cannot be reached through normal component usage. 85.71% coverage indicates - * comprehensive testing of all realistic execution paths. - */ - -// Mock dependencies -jest.mock('@components/compass_icon', () => { - const {Text} = require('react-native'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockCompassIcon({name, size, color, testID}: any) { - const ReactLib = require('react'); - return ReactLib.createElement(Text, { - testID: testID || 'compass-icon', - }, `Icon: ${name}, Size: ${size}, Color: ${color}`); - }; -}); - -jest.mock('@context/theme', () => ({ - useTheme: jest.fn(() => ({ - centerChannelBg: '#ffffff', - centerChannelColor: '#000000', - linkColor: '#0066cc', - errorTextColor: '#d24b47', - })), -})); - -jest.mock('@utils/theme', () => ({ - changeOpacity: jest.fn((color: string, opacity: number) => `${color}(${opacity})`), -})); - -jest.mock('@utils/typography', () => ({ - typography: jest.fn(() => ({ - fontSize: 14, - fontWeight: 'normal', - })), -})); - -describe('BannerItem', () => { - const mockOnPress = jest.fn(); - const mockOnDismiss = jest.fn(); - const mockBannerOnPress = jest.fn(); - - const createMockBanner = (overrides: Partial = {}): BannerItemConfig => ({ - id: 'test-banner-1', - title: 'Test Banner Title', - message: 'This is a test banner message', - type: 'info', - dismissible: true, - onPress: mockBannerOnPress, - ...overrides, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const renderBannerItem = (banner: BannerItemConfig, props: {onPress?: any; onDismiss?: any} = {}) => { - return render( - , - ); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('should render banner with title and message', () => { - const banner = createMockBanner(); - renderBannerItem(banner); - - expect(screen.getByText('Test Banner Title')).toBeTruthy(); - expect(screen.getByText('This is a test banner message')).toBeTruthy(); - }); - - it('should render correct icon for each banner type', () => { - const types = [ - {type: 'info', expectedIcon: 'information'}, - {type: 'success', expectedIcon: 'check-circle'}, - {type: 'warning', expectedIcon: 'alert'}, - {type: 'error', expectedIcon: 'alert-circle'}, - ] as const; - - types.forEach(({type, expectedIcon}) => { - const banner = createMockBanner({type}); - const {unmount} = renderBannerItem(banner); - - expect(screen.getByText(new RegExp(`Icon: ${expectedIcon}`))).toBeTruthy(); - unmount(); - }); - }); - - it('should render default info icon when type is not provided', () => { - const banner = createMockBanner({type: undefined}); - renderBannerItem(banner); - - expect(screen.getByText(/Icon: information/)).toBeTruthy(); - }); - - it('should render dismiss button when dismissible and onDismiss is provided', () => { - const banner = createMockBanner({dismissible: true}); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - expect(screen.getByText(/Icon: close/)).toBeTruthy(); - }); - - it('should not render dismiss button when dismissible is false', () => { - const banner = createMockBanner({dismissible: false}); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - expect(screen.queryByText(/Icon: close/)).toBeNull(); - }); - - it('should not render dismiss button when onDismiss is not provided', () => { - const banner = createMockBanner({dismissible: true}); - renderBannerItem(banner); - - expect(screen.queryByText(/Icon: close/)).toBeNull(); - }); - - it('should render dismiss button by default when onDismiss is provided', () => { - const banner = createMockBanner({dismissible: undefined}); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - expect(screen.getByText(/Icon: close/)).toBeTruthy(); - }); - }); - - describe('press interactions', () => { - it('should call both banner.onPress and onPress prop when pressed', () => { - const banner = createMockBanner(); - renderBannerItem(banner, {onPress: mockOnPress}); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - fireEvent.press(bannerContainer); - - expect(mockBannerOnPress).toHaveBeenCalledTimes(1); - expect(mockOnPress).toHaveBeenCalledWith(banner); - }); - - it('should call only onPress prop when banner.onPress is not provided', () => { - const banner = createMockBanner({onPress: undefined}); - renderBannerItem(banner, {onPress: mockOnPress}); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - fireEvent.press(bannerContainer); - - expect(mockBannerOnPress).not.toHaveBeenCalled(); - expect(mockOnPress).toHaveBeenCalledWith(banner); - }); - - it('should call only banner.onPress when onPress prop is not provided', () => { - const banner = createMockBanner(); - renderBannerItem(banner); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - fireEvent.press(bannerContainer); - - expect(mockBannerOnPress).toHaveBeenCalledTimes(1); - expect(mockOnPress).not.toHaveBeenCalled(); - }); - - it('should not respond to press when neither onPress handlers are provided', () => { - const banner = createMockBanner({onPress: undefined}); - renderBannerItem(banner); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - fireEvent.press(bannerContainer); - - // Neither handler should be called - expect(mockBannerOnPress).not.toHaveBeenCalled(); - expect(mockOnPress).not.toHaveBeenCalled(); - }); - - it('should not disable press when banner.onPress is provided', () => { - const banner = createMockBanner(); - renderBannerItem(banner); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - - expect(bannerContainer.props.disabled).toBeFalsy(); - }); - - it('should not disable press when onPress prop is provided', () => { - const banner = createMockBanner({onPress: undefined}); - renderBannerItem(banner, {onPress: mockOnPress}); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - - expect(bannerContainer.props.disabled).toBeFalsy(); - }); - }); - - describe('dismiss interactions', () => { - it('should call onDismiss with banner when dismiss button is pressed', () => { - const banner = createMockBanner(); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - const dismissButton = screen.getByTestId('banner-dismiss-test-banner-1'); - fireEvent.press(dismissButton); - - expect(mockOnDismiss).toHaveBeenCalledWith(banner); - }); - - it('should not call onDismiss when dismiss button is not present', () => { - const banner = createMockBanner({dismissible: false}); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - // Try to find and press dismiss button (should not exist) - const dismissButton = screen.queryByText(/Icon: close/); - expect(dismissButton).toBeNull(); - expect(mockOnDismiss).not.toHaveBeenCalled(); - }); - - it('should handle dismiss when onDismiss is not provided', () => { - const banner = createMockBanner(); - renderBannerItem(banner); // No onDismiss prop - - // Should not render dismiss button when onDismiss is not provided - const dismissButton = screen.queryByText(/Icon: close/); - expect(dismissButton).toBeNull(); - }); - - }); - - describe('icon colors', () => { - it('should use correct colors for different banner types', () => { - const types = [ - {type: 'success', expectedColor: '#28a745'}, - {type: 'warning', expectedColor: '#ffc107'}, - {type: 'error', expectedColor: '#d24b47'}, - {type: 'info', expectedColor: '#0066cc'}, - ] as const; - - types.forEach(({type, expectedColor}) => { - const banner = createMockBanner({type}); - const {unmount} = renderBannerItem(banner); - - expect(screen.getByText(new RegExp(`Color: ${expectedColor}`))).toBeTruthy(); - unmount(); - }); - }); - }); - - describe('accessibility', () => { - it('should be pressable when handlers are provided', () => { - const banner = createMockBanner(); - renderBannerItem(banner, {onPress: mockOnPress}); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - - expect(bannerContainer.props.disabled).toBeFalsy(); - }); - - it('should not respond to press when no press handlers are provided', () => { - const banner = createMockBanner({onPress: undefined}); - renderBannerItem(banner); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - fireEvent.press(bannerContainer); - - // Should not respond to press - expect(mockBannerOnPress).not.toHaveBeenCalled(); - expect(mockOnPress).not.toHaveBeenCalled(); - }); - }); - - describe('press event handling', () => { - it('should handle pressIn without triggering onPress', () => { - const banner = createMockBanner(); - renderBannerItem(banner, {onPress: mockOnPress}); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - - fireEvent(bannerContainer, 'pressIn'); - - expect(bannerContainer).toBeTruthy(); - expect(mockOnPress).not.toHaveBeenCalled(); // pressIn shouldn't trigger onPress - }); - - it('should handle pressIn on disabled banner', () => { - const banner = createMockBanner({onPress: undefined}); - renderBannerItem(banner); - - const bannerContainer = screen.getByTestId('banner-item-test-banner-1'); - - fireEvent(bannerContainer, 'pressIn'); - - expect(bannerContainer).toBeTruthy(); - }); - - it('should handle dismiss button press events', () => { - const banner = createMockBanner(); - renderBannerItem(banner, {onDismiss: mockOnDismiss}); - - const dismissButton = screen.getByTestId('banner-dismiss-test-banner-1'); - - fireEvent(dismissButton, 'pressIn'); - fireEvent(dismissButton, 'pressOut'); - - expect(dismissButton).toBeTruthy(); - expect(mockOnDismiss).not.toHaveBeenCalled(); // pressIn/pressOut shouldn't trigger onDismiss - }); - - }); - - describe('edge cases', () => { - it('should handle empty title and message gracefully', () => { - const banner = createMockBanner({title: '', message: ''}); - renderBannerItem(banner); - - // Should render without crashing - check for icon presence - expect(screen.getByTestId('compass-icon')).toBeTruthy(); - }); - - it('should handle undefined title and message gracefully', () => { - const banner = createMockBanner({title: undefined, message: undefined}); - renderBannerItem(banner); - - expect(screen.getByTestId('compass-icon')).toBeTruthy(); - const contentContainer = screen.getByTestId('banner-content-test-banner-1'); - expect(contentContainer.children).toHaveLength(0); - }); - - it('should handle very long title and message', () => { - const longTitle = 'Very Long Title '.repeat(50); - const longMessage = 'Very Long Message '.repeat(50); - const banner = createMockBanner({ - title: longTitle, - message: longMessage, - }); - renderBannerItem(banner); - - expect(screen.getByText(longTitle)).toBeTruthy(); - expect(screen.getByText(longMessage)).toBeTruthy(); - }); - - it('should handle special characters in title and message', () => { - const banner = createMockBanner({ - title: 'Title with Γ©mojis πŸŽ‰ and spΓ©ciΓ€l chars', - message: 'Message with & "quotes" and more Γ©mojis πŸš€', - }); - renderBannerItem(banner); - - expect(screen.getByText('Title with Γ©mojis πŸŽ‰ and spΓ©ciΓ€l chars')).toBeTruthy(); - expect(screen.getByText('Message with & "quotes" and more Γ©mojis πŸš€')).toBeTruthy(); - }); - - it('should handle undefined banner type gracefully', () => { - const banner = createMockBanner({type: undefined}); - renderBannerItem(banner); - - // Should default to info icon and color - expect(screen.getByText(/Icon: information/)).toBeTruthy(); - expect(screen.getByText(/Color: #0066cc/)).toBeTruthy(); - }); - }); -}); diff --git a/app/components/banner/banner_item.tsx b/app/components/banner/banner_item.tsx deleted file mode 100644 index a8e807fca..000000000 --- a/app/components/banner/banner_item.tsx +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback} from 'react'; -import {View, Text, Pressable, StyleSheet} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import {useTheme} from '@context/theme'; -import {changeOpacity} from '@utils/theme'; -import {typography} from '@utils/typography'; - -export interface BannerItemConfig { - id: string; - title?: string; - message?: string; - type?: 'info' | 'success' | 'warning' | 'error'; - - /** - * Whether the banner can be dismissed - * @default true - */ - dismissible?: boolean; - onPress?: () => void; -} - -interface BannerItemProps { - banner: BannerItemConfig; - onPress?: (banner: BannerItemConfig) => void; - onDismiss?: (banner: BannerItemConfig) => void; -} - -const getStyleSheet = (theme: Theme) => StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.centerChannelBg, - borderRadius: 8, - padding: 8, - marginHorizontal: 8, - marginVertical: 4, - height: 40, - shadowColor: theme.centerChannelColor, - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 4, - }, - iconContainer: { - marginRight: 8, - width: 20, - alignItems: 'center', - }, - content: { - flex: 1, - }, - title: { - ...typography('Body', 75, 'SemiBold'), - color: theme.centerChannelColor, - marginBottom: 1, - }, - message: { - ...typography('Body', 50), - color: changeOpacity(theme.centerChannelColor, 0.7), - }, - dismissButton: { - marginLeft: 12, - padding: 4, - }, - pressed: { - opacity: 0.7, - }, - dismissPressed: { - opacity: 0.5, - }, - info: { - borderLeftWidth: 4, - borderLeftColor: theme.linkColor, - }, - success: { - borderLeftWidth: 4, - borderLeftColor: '#28a745', - }, - warning: { - borderLeftWidth: 4, - borderLeftColor: '#ffc107', - }, - error: { - borderLeftWidth: 4, - borderLeftColor: theme.errorTextColor, - }, -}); - -const getBannerIconName = (type: string): string => { - switch (type) { - case 'success': - return 'check-circle'; - case 'warning': - return 'alert'; - case 'error': - return 'alert-circle'; - default: - return 'information'; - } -}; - -const getBannerIconColor = (type: string, theme: Theme): string => { - switch (type) { - case 'success': - return '#28a745'; - case 'warning': - return '#ffc107'; - case 'error': - return theme.errorTextColor; - default: - return theme.linkColor; - } -}; - -const BannerItem: React.FC = ({banner, onPress, onDismiss}) => { - const theme = useTheme(); - const styles = getStyleSheet(theme); - - const handlePress = useCallback(() => { - if (banner.onPress) { - banner.onPress(); - } - if (onPress) { - onPress(banner); - } - }, [banner, onPress]); - - const handleDismiss = useCallback(() => { - if (onDismiss) { - onDismiss(banner); - } - }, [banner, onDismiss]); - - return ( - [ - styles.container, - styles[banner.type || 'info'], - pressed && (banner.onPress || onPress) && styles.pressed, - ]} - onPress={handlePress} - disabled={!banner.onPress && !onPress} - > - - - - - - {banner.title && {banner.title}} - {banner.message && {banner.message}} - - - {banner.dismissible !== false && onDismiss && ( - [ - styles.dismissButton, - pressed && styles.dismissPressed, - ]} - onPress={handleDismiss} - > - - - )} - - ); -}; - -export default BannerItem; diff --git a/app/components/banner/hooks/useBanner.test.ts b/app/components/banner/hooks/useBanner.test.ts deleted file mode 100644 index 1a2b588fc..000000000 --- a/app/components/banner/hooks/useBanner.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {renderHook} from '@testing-library/react-native'; - -import {useBanner} from './useBanner'; - -jest.mock('react-native-reanimated', () => { - const actualReanimated = jest.requireActual('react-native-reanimated/mock'); - return { - ...actualReanimated, - useSharedValue: jest.fn((initial) => ({value: initial})), - useAnimatedStyle: jest.fn((callback) => callback()), - withTiming: jest.fn((value) => value), - runOnJS: jest.fn((fn) => fn), - }; -}); - -jest.mock('react-native-gesture-handler', () => ({ - Gesture: { - Pan: jest.fn(() => ({ - onStart: jest.fn().mockReturnThis(), - onUpdate: jest.fn().mockReturnThis(), - onEnd: jest.fn().mockReturnThis(), - })), - }, -})); - -describe('useBanner', () => { - const defaultProps = { - animationDuration: 250, - dismissible: true, - swipeThreshold: 100, - onDismiss: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns animatedStyle and swipeGesture', () => { - const {result} = renderHook(() => useBanner(defaultProps)); - - expect(result.current).toHaveProperty('animatedStyle'); - expect(result.current).toHaveProperty('swipeGesture'); - }); - - it('initializes with visible state', () => { - const {result} = renderHook(() => useBanner(defaultProps)); - - expect(result.current.animatedStyle).toBeDefined(); - }); - - it('creates gesture when dismissible is true', () => { - const {result} = renderHook(() => useBanner({ - ...defaultProps, - dismissible: true, - })); - - expect(result.current.swipeGesture).toBeDefined(); - }); - - it('creates gesture when dismissible is false', () => { - const {result} = renderHook(() => useBanner({ - ...defaultProps, - dismissible: false, - })); - - expect(result.current.swipeGesture).toBeDefined(); - }); -}); - diff --git a/app/components/banner/hooks/useBanner.ts b/app/components/banner/hooks/useBanner.ts deleted file mode 100644 index 9f5344d16..000000000 --- a/app/components/banner/hooks/useBanner.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Gesture} from 'react-native-gesture-handler'; -import {runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; - -const SWIPE_DISMISS_ANIMATION_DURATION = 200; - -interface UseBannerProps { - animationDuration: number; - dismissible: boolean; - swipeThreshold: number; - onDismiss?: () => void; -} - -export const useBanner = ({ - animationDuration, - dismissible, - swipeThreshold, - onDismiss, -}: UseBannerProps) => { - const opacity = useSharedValue(1); - const translateY = useSharedValue(0); - const translateX = useSharedValue(0); - const isDismissed = useSharedValue(false); - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: withTiming(opacity.value, {duration: animationDuration}), - transform: [ - { - translateY: withTiming(translateY.value, {duration: animationDuration}), - }, - { - translateX: translateX.value, - }, - ], - })); - - const startX = useSharedValue(0); - - const swipeGesture = Gesture.Pan(). - onStart(() => { - startX.value = translateX.value; - }). - onUpdate((event) => { - if (!dismissible) { - return; - } - - translateX.value = startX.value + event.translationX; - }). - onEnd(() => { - if (!dismissible) { - return; - } - - const shouldDismiss = Math.abs(translateX.value) > swipeThreshold; - - if (shouldDismiss && !isDismissed.value) { - isDismissed.value = true; - translateX.value = withTiming( - translateX.value > 0 ? 300 : -300, - {duration: SWIPE_DISMISS_ANIMATION_DURATION}, - ); - opacity.value = withTiming(0, {duration: SWIPE_DISMISS_ANIMATION_DURATION}); - - if (onDismiss) { - runOnJS(onDismiss)(); - } - } else { - translateX.value = withTiming(0, {duration: SWIPE_DISMISS_ANIMATION_DURATION}); - } - }); - - return { - animatedStyle, - swipeGesture, - }; -}; - diff --git a/app/components/banner/types.ts b/app/components/banner/types.ts deleted file mode 100644 index df833fd87..000000000 --- a/app/components/banner/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {StyleProp, ViewStyle} from 'react-native'; - -/** - * Props for the Banner component - * - * A flexible banner component that positions itself absolutely on screen - * with smart offset calculations based on existing UI elements. - */ -export interface BannerProps { - - /** The content to display inside the banner */ - children: React.ReactNode; - - /** - * Duration of fade animation in milliseconds - * @default 300 - */ - animationDuration?: number; - - /** - * Custom styles applied to the banner content - */ - style?: StyleProp; - - /** - * Whether the banner can be dismissed by swiping - * @default false - */ - dismissible?: boolean; - - /** - * Callback called when banner is dismissed via swipe - */ - onDismiss?: () => void; - - /** - * Swipe threshold to trigger dismiss (in pixels) - * @default 100 - */ - swipeThreshold?: number; -} - diff --git a/app/components/connection_banner/connection_banner.tsx b/app/components/connection_banner/connection_banner.tsx index dcbe0ba90..d51539223 100644 --- a/app/components/connection_banner/connection_banner.tsx +++ b/app/components/connection_banner/connection_banner.tsx @@ -1,112 +1,202 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Text, View, Pressable} from 'react-native'; +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 {changeOpacity, makeStyleSheetFromTheme} from '@utils/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; - message: string; - dismissible?: boolean; - onDismiss?: () => void; + websocketState: WebsocketConnectedState; } const getStyle = makeStyleSheetFromTheme((theme: Theme) => { + const bannerContainer = { + flex: 1, + paddingHorizontal: 10, + overflow: 'hidden' as const, + flexDirection: 'row' as const, + alignItems: 'center' as const, + marginHorizontal: 8, + borderRadius: 7, + }; return { - container: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 8, - height: 40, - shadowColor: theme.centerChannelColor, - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 4, + background: { + backgroundColor: theme.sidebarBg, + zIndex: 1, }, - containerNotConnected: { + bannerContainerNotConnected: { + ...bannerContainer, backgroundColor: theme.centerChannelColor, }, - containerConnected: { + bannerContainerConnected: { + ...bannerContainer, backgroundColor: theme.onlineIndicator, }, - content: { + wrapper: { + flexDirection: 'row', flex: 1, - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, + overflow: 'hidden', }, - text: { - ...typography('Body', 100, 'SemiBold'), + bannerTextContainer: { + flex: 1, + flexGrow: 1, + marginRight: 5, + textAlign: 'center', color: theme.centerChannelBg, - textAlign: 'center' as const, }, - icon: { - marginRight: 8, - }, - dismissButton: { - marginLeft: 8, - padding: 4, - }, - dismissPressed: { - opacity: 0.5, + bannerText: { + ...typography('Body', 100, 'SemiBold'), }, }; }); -const ConnectionBanner: React.FC = ({isConnected, message, dismissible = false, onDismiss}) => { +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, +}: 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 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); + }; + }, []); + + 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']); + + useEffect(() => { + height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, { + duration: 200, + }); + }, [height, visible]); + + const bannerStyle = useAnimatedStyle(() => ({ + 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: 'No internet connection'}); + } return ( - - - - + {visible && + - {message} - + + + {' '} + + {text} + + + + } - - {dismissible && onDismiss && ( - [ - style.dismissButton, - pressed && style.dismissPressed, - ]} - onPress={onDismiss} - > - - - )} - + ); }; diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts new file mode 100644 index 000000000..4b12644a5 --- /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/watermelondb/react'; + +import {withServerUrl} from '@context/server'; +import WebsocketManager from '@managers/websocket_manager'; + +import ConnectionBanner from './connection_banner'; + +const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ + websocketState: WebsocketManager.observeWebsocketState(serverUrl), +})); + +export default withServerUrl(enhanced(ConnectionBanner)); diff --git a/app/components/floating_banner/animated_banner_item.test.tsx b/app/components/floating_banner/animated_banner_item.test.tsx deleted file mode 100644 index e80df7e59..000000000 --- a/app/components/floating_banner/animated_banner_item.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render, fireEvent, screen} from '@testing-library/react-native'; -import React from 'react'; -import {Text} from 'react-native'; - -import AnimatedBannerItem from './animated_banner_item'; - -import type {FloatingBannerConfig} from './types'; - -jest.mock('react-native-reanimated', () => { - const {View} = require('react-native'); - const ReactLib = require('react'); - - const AnimatedView = ReactLib.forwardRef((props: Record, ref: unknown) => { - return ReactLib.createElement(View, {...props, ref}); - }); - AnimatedView.displayName = 'AnimatedView'; - - return { - __esModule: true, - useAnimatedStyle: (fn: () => unknown) => fn(), - useSharedValue: (initial: unknown) => ({value: initial}), - withTiming: (value: unknown) => value, - withDelay: (_delay: number, value: unknown) => value, - default: { - View: AnimatedView, - }, - }; -}); - -jest.mock('@components/banner/Banner', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner', ...props}, - children, - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-dismiss', onPress: onDismiss}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -jest.mock('@components/banner/banner_item', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner-item', 'data-banner-id': banner.id}, - mockReact.createElement(mockText, null, banner.title), - mockReact.createElement(mockText, null, banner.message), - onPress && mockReact.createElement( - mockPressable, - {testID: 'banner-item-press', onPress: () => onPress(banner)}, - mockReact.createElement(mockText, null, 'Press'), - ), - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -jest.mock('@hooks/header', () => ({ - useDefaultHeaderHeight: jest.fn(() => 64), -})); - -describe('AnimatedBannerItem', () => { - const mockOnBannerPress = jest.fn(); - const mockOnBannerDismiss = jest.fn(); - - const createMockBanner = (overrides: Partial = {}): FloatingBannerConfig => ({ - id: 'test-banner-1', - title: 'Test Banner', - message: 'This is a test message', - type: 'info', - dismissible: true, - ...overrides, - }); - - const renderAnimatedBannerItem = ( - banner: FloatingBannerConfig, - index: number = 0, - ) => { - return render( - , - ); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('should render banner with correct props', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner); - - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement.props.dismissible).toBe(true); - }); - - it('should render BannerItem when no custom component is provided', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner); - - const bannerItem = screen.getByTestId('banner-item'); - expect(bannerItem.props['data-banner-id']).toBe('test-banner-1'); - }); - - it('should render custom component when provided', () => { - const customComponent = {'Custom Banner Content'}; - const banner = createMockBanner({customComponent}); - renderAnimatedBannerItem(banner); - - expect(screen.getByTestId('custom-content')).toBeDefined(); - expect(screen.queryByTestId('banner-item')).toBeNull(); - }); - - it('should handle non-dismissible banners', () => { - const banner = createMockBanner({dismissible: false}); - renderAnimatedBannerItem(banner); - - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement.props.dismissible).toBe(false); - }); - - it('should handle banner with dismissible undefined (defaults to true)', () => { - const banner = createMockBanner(); - delete banner.dismissible; - renderAnimatedBannerItem(banner); - - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement.props.dismissible).toBe(true); - }); - - it('should render BannerItem when customComponent is falsy', () => { - const banner = createMockBanner({customComponent: null}); - renderAnimatedBannerItem(banner); - - const bannerItem = screen.getByTestId('banner-item'); - expect(bannerItem.props['data-banner-id']).toBe('test-banner-1'); - expect(screen.queryByTestId('custom-content')).toBeNull(); - }); - }); - - describe('event handlers', () => { - it('should call onBannerPress when banner item is pressed', () => { - const banner = createMockBanner({onPress: jest.fn()}); - renderAnimatedBannerItem(banner); - - const pressButton = screen.getByTestId('banner-item-press'); - fireEvent.press(pressButton); - - expect(mockOnBannerPress).toHaveBeenCalledWith(banner); - }); - - it('should call onBannerDismiss when banner item dismiss is pressed', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner); - - const dismissButton = screen.getByTestId('banner-item-dismiss'); - fireEvent.press(dismissButton); - - expect(mockOnBannerDismiss).toHaveBeenCalledWith(banner); - }); - - it('should call onBannerDismiss when Banner dismiss is pressed', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner); - - const bannerDismissButton = screen.getByTestId('banner-dismiss'); - fireEvent.press(bannerDismissButton); - - expect(mockOnBannerDismiss).toHaveBeenCalledWith(banner); - }); - }); - - describe('positioning', () => { - it('should render for top position', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner, 0); - - expect(screen.getByTestId('banner')).toBeDefined(); - }); - - it('should render for bottom position', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner, 0); - - expect(screen.getByTestId('banner')).toBeDefined(); - }); - - it('should render on tablet with top position', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner, 0); - - expect(screen.getByTestId('banner')).toBeDefined(); - }); - - it('should render on tablet with bottom position', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner, 0); - - expect(screen.getByTestId('banner')).toBeDefined(); - }); - - it('should render at different index positions', () => { - const banner = createMockBanner(); - renderAnimatedBannerItem(banner, 2); - - expect(screen.getByTestId('banner')).toBeDefined(); - }); - }); - - describe('custom content', () => { - it('should render custom content for a bottom-positioned banner', () => { - const customComponent = {'Bottom Custom'}; - const banner = createMockBanner({customComponent, dismissible: false}); - renderAnimatedBannerItem(banner, 0); - - expect(screen.getByTestId('custom-content-bottom')).toBeDefined(); - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement.props.dismissible).toBe(false); - }); - - it('should render custom content without title and message', () => { - const customComponent = {'Custom Banner'}; - const banner = createMockBanner({title: undefined, message: undefined, customComponent}); - renderAnimatedBannerItem(banner); - - expect(screen.getByTestId('custom-no-text')).toBeDefined(); - expect(screen.queryByTestId('banner-item')).toBeNull(); - }); - }); -}); - diff --git a/app/components/floating_banner/animated_banner_item.tsx b/app/components/floating_banner/animated_banner_item.tsx deleted file mode 100644 index 4efceec9c..000000000 --- a/app/components/floating_banner/animated_banner_item.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useEffect, useRef} from 'react'; -import {StyleSheet, View} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; - -import Banner from '@components/banner/Banner'; -import BannerItem from '@components/banner/banner_item'; -import { - BANNER_SPACING, -} from '@constants/view'; - -import type {FloatingBannerConfig} from './types'; - -type AnimatedBannerItemProps = { - banner: FloatingBannerConfig; - index: number; - onBannerPress: (banner: FloatingBannerConfig) => void; - onBannerDismiss: (banner: FloatingBannerConfig) => void; -}; - -const styles = StyleSheet.create({ - bannerContainer: { - width: '100%', - alignItems: 'center', - }, - animatedBannerWrapper: { - width: '100%', - }, -}); - -const AnimatedBannerItem: React.FC = ({ - banner, - index, - onBannerPress, - onBannerDismiss, -}) => { - const {dismissible = true, customComponent} = banner; - - const opacity = useSharedValue(0); - const translateY = useSharedValue(20); - const hasAnimated = useRef(false); - - const handleDismiss = useCallback(() => onBannerDismiss(banner), [banner, onBannerDismiss]); - - useEffect(() => { - if (hasAnimated.current) { - return; - } - hasAnimated.current = true; - opacity.value = withTiming(1, {duration: 250}); - translateY.value = withTiming(0, {duration: 250}); - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- opacity and translateY are sharedValue(s) - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [{translateY: translateY.value}], - })); - - return ( - 0 ? BANNER_SPACING : 0}, - ]} - > - - - {customComponent || ( - - )} - - - - ); -}; - -export default AnimatedBannerItem; - diff --git a/app/components/floating_banner/banner_section.test.tsx b/app/components/floating_banner/banner_section.test.tsx deleted file mode 100644 index c74620f23..000000000 --- a/app/components/floating_banner/banner_section.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render, screen} from '@testing-library/react-native'; -import React from 'react'; - -import * as Device from '@hooks/device'; -import {useBannerGestureRootPosition} from '@hooks/useBannerGestureRootPosition'; - -import BannerSection from './banner_section'; - -import type {FloatingBannerConfig} from './types'; - -jest.mock('@hooks/device', () => ({ - useIsTablet: jest.fn(), - useKeyboardHeight: jest.fn(), - useWindowDimensions: jest.fn(() => ({width: 375, height: 812})), -})); - -jest.mock('@hooks/useBannerGestureRootPosition', () => ({ - useBannerGestureRootPosition: jest.fn(), -})); - -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: jest.fn(() => ({top: 44, bottom: 0, left: 0, right: 0})), -})); - -jest.mock('react-native-reanimated', () => { - const {View} = require('react-native'); - const ReactLib = require('react'); - - const AnimatedView = ReactLib.forwardRef((props: Record, ref: unknown) => { - return ReactLib.createElement(View, {...props, ref}); - }); - AnimatedView.displayName = 'AnimatedView'; - - return { - __esModule: true, - useSharedValue: (initialValue: unknown) => ({value: initialValue}), - useAnimatedStyle: (fn: () => unknown) => fn(), - withTiming: (value: unknown) => value, - default: { - View: AnimatedView, - createAnimatedComponent: (component: unknown) => component, - }, - }; -}); - -jest.mock('@components/banner/Banner', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner', ...props}, - children, - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-dismiss', onPress: onDismiss}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -jest.mock('@components/banner/banner_item', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner-item', 'data-banner-id': banner.id}, - mockReact.createElement(mockText, null, banner.title), - mockReact.createElement(mockText, null, banner.message), - onPress && mockReact.createElement( - mockPressable, - {testID: 'banner-item-press', onPress: () => onPress(banner)}, - mockReact.createElement(mockText, null, 'Press'), - ), - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -describe('BannerSection', () => { - const mockOnBannerPress = jest.fn(); - const mockOnBannerDismiss = jest.fn(); - - const createMockBanner = (overrides: Partial = {}): FloatingBannerConfig => ({ - id: 'test-banner-1', - title: 'Test Banner', - message: 'This is a test message', - type: 'info', - dismissible: true, - ...overrides, - }); - - const renderBannerSection = ( - sectionBanners: FloatingBannerConfig[], - position: 'top' | 'bottom' = 'top', - ) => { - return render( - , - ); - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.mocked(Device.useIsTablet).mockReturnValue(false); - jest.mocked(Device.useKeyboardHeight).mockReturnValue(0); - jest.mocked(useBannerGestureRootPosition).mockReturnValue({ - height: 40, - top: 0, - }); - }); - - describe('rendering', () => { - it('should return null when no banners are provided', () => { - renderBannerSection([], 'top'); - - expect(screen.queryByTestId('floating-banner-top-container')).toBeNull(); - expect(screen.queryByTestId('floating-banner-bottom-container')).toBeNull(); - }); - - it('should render top container with correct testID', () => { - const banners = [createMockBanner()]; - renderBannerSection(banners, 'top'); - - expect(screen.getByTestId('floating-banner-top-container')).toBeDefined(); - }); - - it('should render bottom container with correct testID', () => { - const banners = [createMockBanner()]; - renderBannerSection(banners, 'bottom'); - - expect(screen.getByTestId('floating-banner-bottom-container')).toBeDefined(); - }); - - it('should render multiple banners', () => { - const banners = [ - createMockBanner({id: 'banner-1'}), - createMockBanner({id: 'banner-2'}), - createMockBanner({id: 'banner-3'}), - ]; - renderBannerSection(banners); - - const bannerElements = screen.getAllByTestId('banner'); - expect(bannerElements).toHaveLength(3); - }); - }); -}); - diff --git a/app/components/floating_banner/banner_section.tsx b/app/components/floating_banner/banner_section.tsx deleted file mode 100644 index 5eb0256de..000000000 --- a/app/components/floating_banner/banner_section.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useMemo} from 'react'; -import {StyleSheet} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import Animated, {useAnimatedStyle} from 'react-native-reanimated'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; - -import { - BANNER_SPACING, -} from '@constants/view'; -import {useBannerGestureRootPosition} from '@hooks/useBannerGestureRootPosition'; - -import AnimatedBannerItem from './animated_banner_item'; - -import type {FloatingBannerConfig, FloatingBannerPosition} from './types'; - -type BannerSectionProps = { - sectionBanners: FloatingBannerConfig[]; - position: FloatingBannerPosition; - onBannerPress: (banner: FloatingBannerConfig) => void; - onBannerDismiss: (banner: FloatingBannerConfig) => void; -}; - -const BANNER_HEIGHT = 40; - -const styles = StyleSheet.create({ - gestureRoot: { - position: 'absolute', - width: '100%', - }, - containerBase: { - width: '100%', - paddingHorizontal: 16, - alignItems: 'center', - }, - topContainer: { - top: 0, - paddingTop: 8, - }, - bottomContainer: { - paddingBottom: 8, - }, -}); - -const BannerSection: React.FC = ({ - sectionBanners, - position, - onBannerPress, - onBannerDismiss, -}) => { - if (!sectionBanners.length) { - return null; - } - - const insets = useSafeAreaInsets(); - const isTop = position === 'top'; - const testID = isTop ? 'floating-banner-top-container' : 'floating-banner-bottom-container'; - - const containerStyle = isTop ? styles.topContainer :styles.bottomContainer; - - const animatedStyle = useAnimatedStyle(() => { - if (isTop) { - return {paddingTop: insets.top + 8}; - } - - return {}; - }, [isTop, insets.top]); - - // Limitation: Assumes all banners have the same fixed height (BANNER_HEIGHT). - // If banners have different heights, the gesture area may not align perfectly. - // Future improvement: Measure actual banner heights using onLayout for dynamic sizing. - const containerHeight = useMemo(() => { - const numBanners = sectionBanners.length; - const spacing = (numBanners - 1) * BANNER_SPACING; - return (BANNER_HEIGHT * numBanners) + spacing; - }, [sectionBanners.length]); - - const gestureRootStyle = useBannerGestureRootPosition({ - position, - containerHeight, - }); - - return ( - - - {sectionBanners.map((banner, index) => ( - - ))} - - - ); -}; - -export default BannerSection; - diff --git a/app/components/floating_banner/floating_banner.test.tsx b/app/components/floating_banner/floating_banner.test.tsx deleted file mode 100644 index a8e6b14a5..000000000 --- a/app/components/floating_banner/floating_banner.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {render, fireEvent, screen} from '@testing-library/react-native'; -import React from 'react'; -import {Text} from 'react-native'; - -import * as Device from '@hooks/device'; - -import FloatingBanner from './floating_banner'; - -import type {FloatingBannerConfig} from './types'; - -jest.mock('@hooks/device'); - -jest.mock('@hooks/useBannerGestureRootPosition', () => ({ - useBannerGestureRootPosition: jest.fn(() => ({ - height: 40, - top: 0, - })), -})); - -jest.mock('react-native-reanimated', () => { - const {View} = require('react-native'); - const ReactLib = require('react'); - - const AnimatedView = ReactLib.forwardRef((props: Record, ref: unknown) => { - return ReactLib.createElement(View, {...props, ref}); - }); - AnimatedView.displayName = 'AnimatedView'; - - return { - __esModule: true, - useAnimatedStyle: (fn: () => unknown) => fn(), - useSharedValue: (initialValue: unknown) => ({value: initialValue}), - withTiming: (value: unknown) => value, - createAnimatedComponent: (component: unknown) => component, - default: { - View: AnimatedView, - createAnimatedComponent: (component: unknown) => component, - }, - }; -}); - -jest.mock('@components/banner/Banner', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner', ...props}, - children, - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-dismiss', onPress: onDismiss}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -jest.mock('@components/banner/banner_item', () => { - const mockView = require('react-native').View; - const mockText = require('react-native').Text; - const mockPressable = require('react-native').Pressable; - const mockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) { - return mockReact.createElement( - mockView, - {testID: 'banner-item', 'data-banner-id': banner.id}, - mockReact.createElement(mockText, null, banner.title), - mockReact.createElement(mockText, null, banner.message), - onPress && mockReact.createElement( - mockPressable, - {testID: 'banner-item-press', onPress: () => onPress(banner)}, - mockReact.createElement(mockText, null, 'Press'), - ), - onDismiss && mockReact.createElement( - mockPressable, - {testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)}, - mockReact.createElement(mockText, null, 'Dismiss'), - ), - ); - }; -}); - -describe('FloatingBanner', () => { - const mockOnPress = jest.fn(); - const mockOnDismiss = jest.fn(); - - const createMockBanner = (overrides: Partial = {}): FloatingBannerConfig => ({ - id: 'test-banner-1', - title: 'Test Banner', - message: 'This is a test message', - type: 'info', - dismissible: true, - onPress: mockOnPress, - onDismiss: mockOnDismiss, - ...overrides, - }); - - const mockOverlayOnDismiss = jest.fn(); - - const renderFloatingBanner = (banners: FloatingBannerConfig[] = []) => { - return render( - , - ); - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockOverlayOnDismiss.mockClear(); - - jest.mocked(Device.useIsTablet).mockReturnValue(false); - jest.mocked(Device.useKeyboardHeight).mockReturnValue(0); - }); - - describe('rendering', () => { - it('should render nothing when no banners are present', () => { - renderFloatingBanner([]); - - expect(screen.queryByTestId('banner')).toBeNull(); - }); - - it('should render single banner with correct props', () => { - const banner = createMockBanner(); - renderFloatingBanner([banner]); - - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement.props.dismissible).toBe(true); - }); - - it('should render multiple banners with correct offsets', () => { - const banners = [ - createMockBanner({id: 'banner-1'}), - createMockBanner({id: 'banner-2'}), - createMockBanner({id: 'banner-3'}), - ]; - renderFloatingBanner(banners); - - const bannerElements = screen.getAllByTestId('banner'); - expect(bannerElements).toHaveLength(3); - }); - - it('should use banner position when specified', () => { - const banner = createMockBanner({position: 'bottom'}); - renderFloatingBanner([banner]); - - const bannerElement = screen.getByTestId('banner'); - expect(bannerElement).toBeDefined(); - }); - }); - - describe('event handlers', () => { - it('should call banner onPress when onBannerPress is triggered', () => { - const banner = createMockBanner(); - renderFloatingBanner([banner]); - - const pressButton = screen.getByTestId('banner-item-press'); - fireEvent.press(pressButton); - - expect(mockOnPress).toHaveBeenCalledTimes(1); - }); - - it('should not call onPress when banner has no onPress handler', () => { - const banner = createMockBanner({onPress: undefined}); - renderFloatingBanner([banner]); - - const pressButton = screen.getByTestId('banner-item-press'); - fireEvent.press(pressButton); - - expect(mockOnPress).not.toHaveBeenCalled(); - }); - - it('should call onDismiss and banner onDismiss when onBannerDismiss is triggered', () => { - const banner = createMockBanner(); - renderFloatingBanner([banner]); - - const dismissButton = screen.getByTestId('banner-item-dismiss'); - fireEvent.press(dismissButton); - - expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1'); - expect(mockOnDismiss).toHaveBeenCalledTimes(1); - }); - - it('should only call onDismiss when banner has no onDismiss handler', () => { - const banner = createMockBanner({onDismiss: undefined}); - renderFloatingBanner([banner]); - - const dismissButton = screen.getByTestId('banner-item-dismiss'); - fireEvent.press(dismissButton); - - expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1'); - }); - - it('should call onBannerDismiss when Banner onDismiss is triggered', () => { - const banner = createMockBanner(); - renderFloatingBanner([banner]); - - const bannerDismissButton = screen.getByTestId('banner-dismiss'); - fireEvent.press(bannerDismissButton); - - expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1'); - }); - }); - - describe('banner positioning', () => { - it('should separate banners by position (top vs bottom)', () => { - const banners = [ - createMockBanner({ - id: 'info-banner', - type: 'info', - position: 'top', - dismissible: true, - }), - createMockBanner({ - id: 'error-banner', - type: 'error', - position: 'bottom', - dismissible: false, - }), - createMockBanner({ - id: 'custom-banner', - customComponent: {'Custom'}, - }), - ]; - renderFloatingBanner(banners); - - const bannerElements = screen.getAllByTestId('banner'); - expect(bannerElements).toHaveLength(3); - - expect(screen.getByTestId('floating-banner-top-container')).toBeDefined(); - expect(screen.getByTestId('floating-banner-bottom-container')).toBeDefined(); - }); - }); - - describe('position filtering', () => { - it('uses default top when position is undefined', () => { - const banners = [ - createMockBanner({id: 'no-pos-1', position: undefined}), - createMockBanner({id: 'no-pos-2', position: undefined}), - ]; - renderFloatingBanner(banners); - - const bannerElements = screen.getAllByTestId('banner'); - expect(bannerElements).toHaveLength(2); - }); - }); -}); diff --git a/app/components/floating_banner/floating_banner.tsx b/app/components/floating_banner/floating_banner.tsx deleted file mode 100644 index 0462c50d5..000000000 --- a/app/components/floating_banner/floating_banner.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useMemo} from 'react'; - -import BannerSection from './banner_section'; - -import type {FloatingBannerConfig} from './types'; - -type FloatingBannerProps = { - banners: FloatingBannerConfig[]; - onDismiss: (id: string) => void; -}; - -const FloatingBanner: React.FC = ({banners, onDismiss}) => { - if (!banners || !banners.length) { - return null; - } - - const onBannerPress = useCallback((banner: FloatingBannerConfig) => { - if (banner.onPress) { - banner.onPress(); - } - }, []); - - const onBannerDismiss = useCallback((banner: FloatingBannerConfig) => { - onDismiss(banner.id); - if (banner.onDismiss) { - banner.onDismiss(); - } - }, [onDismiss]); - - const topBanners = useMemo( - () => banners.filter((banner) => (banner.position || 'top') === 'top'), - [banners], - ); - - const bottomBanners = useMemo( - () => banners.filter((banner) => banner.position === 'bottom'), - [banners], - ); - - return ( - <> - - - - ); -}; - -export default FloatingBanner; diff --git a/app/components/floating_banner/types.ts b/app/components/floating_banner/types.ts deleted file mode 100644 index e071da34c..000000000 --- a/app/components/floating_banner/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {ReactNode} from 'react'; - -export type FloatingBannerPosition = 'top' | 'bottom'; - -export interface FloatingBannerConfig { - id: string; - title?: string; - message?: string; - type?: 'info' | 'success' | 'warning' | 'error'; - dismissible?: boolean; - autoHideDuration?: number; - position?: FloatingBannerPosition; - onPress?: () => void; - onDismiss?: () => void; - customComponent?: ReactNode; -} diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 05c124dfe..fcc447754 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -48,17 +48,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { actionContainer: { flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', justifyContent: 'flex-end', - marginTop: 2, }, container: { flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', minHeight: ITEM_HEIGHT, gap: 12, justifyContent: 'space-between', - paddingVertical: 12, }, destructive: { color: theme.dndIndicator, diff --git a/app/constants/database.ts b/app/constants/database.ts index b9da06fad..a487e6025 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -84,10 +84,9 @@ export const GLOBAL_IDENTIFIERS = { DONT_ASK_FOR_REVIEW: 'dontAskForReview', FIRST_LAUNCH: 'firstLaunch', LAST_ASK_FOR_REVIEW: 'lastAskForReview', + ONBOARDING: 'onboarding', LAST_VIEWED_CHANNEL: 'lastViewedChannel', LAST_VIEWED_THREAD: 'lastViewedThread', - LOW_CONNECTIVITY_MONITOR: 'lowConnectivityMonitor', - ONBOARDING: 'onboarding', PUSH_DISABLED_ACK: 'pushDisabledAck', }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 867062d64..7ed8f3d83 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -36,7 +36,6 @@ export const FIND_CHANNELS = 'FindChannels'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const GALLERY = 'Gallery'; export const GENERIC_OVERLAY = 'GenericOverlay'; -export const FLOATING_BANNER = 'FloatingBanner'; export const GLOBAL_DRAFTS = 'GlobalDrafts'; export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts'; export const GLOBAL_THREADS = 'GlobalThreads'; @@ -124,7 +123,6 @@ export default { FORGOT_PASSWORD, GALLERY, GENERIC_OVERLAY, - FLOATING_BANNER, GLOBAL_DRAFTS, GLOBAL_DRAFTS_AND_SCHEDULED_POSTS, GLOBAL_THREADS, @@ -205,7 +203,6 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set([ REVIEW_APP, SNACK_BAR, GENERIC_OVERLAY, - FLOATING_BANNER, ]); export const SCREENS_AS_BOTTOM_SHEET = new Set([ diff --git a/app/constants/view.ts b/app/constants/view.ts index 0dff6e5bf..6a33c9816 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -33,15 +33,6 @@ export const ANNOUNCEMENT_BAR_HEIGHT = 40; export const BOOKMARKS_BAR_HEIGHT = 48; export const CHANNEL_BANNER_HEIGHT = 40; -export const FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS = 121; -export const FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID = 98; -export const FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS = 78; -export const FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID = 88; -export const FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET = 68; -export const FLOATING_BANNER_STACK_SPACING = 60; -export const FLOATING_BANNER_EXTRA_OFFSET = 8; -export const BANNER_SPACING = 8; - export const HOME_PADDING = { paddingLeft: 18, paddingRight: 20, diff --git a/app/hooks/device.ts b/app/hooks/device.ts index 20c891cbf..abca58490 100644 --- a/app/hooks/device.ts +++ b/app/hooks/device.ts @@ -12,8 +12,6 @@ import type {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-v let utilsEmitter = new NativeEventEmitter(RNUtils); -const isOlderAndroidOS = Platform.OS === 'android' && Platform.Version < 30; - export function testSetUtilsEmitter(emitter: NativeEventEmitter) { utilsEmitter = emitter; } @@ -62,17 +60,9 @@ export function useKeyboardHeightWithDuration() { const insets = useSafeAreaInsets(); useEffect(() => { - if (Platform.OS === 'ios') { - const currentKeyboardMetrics = Keyboard.metrics(); - - if (Keyboard.isVisible() && currentKeyboardMetrics) { - setKeyboardHeight({height: currentKeyboardMetrics.height, duration: 0}); - } - } - const show = Keyboard.addListener(Platform.select({ios: 'keyboardWillShow', default: 'keyboardDidShow'}), async (event) => { // Do not use set the height on Android versions below 11 - if (isOlderAndroidOS) { + if (Platform.OS === 'android' && Platform.Version < 30) { return; } setKeyboardHeight({height: event.endCoordinates.height, duration: event.duration}); @@ -80,7 +70,7 @@ export function useKeyboardHeightWithDuration() { const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), (event) => { // Do not use set the height on Android versions below 11 - if (isOlderAndroidOS) { + if (Platform.OS === 'android' && Platform.Version < 30) { return; } @@ -118,7 +108,7 @@ export function useViewPosition(viewRef: RefObject, deps: React.Dependency } }); } - }, [...deps, isTablet, height, viewRef, modalPosition]); // eslint-disable-line react-hooks/exhaustive-deps -- can't verify ...deps + }, [...deps, isTablet, height, viewRef, modalPosition]); return modalPosition; } diff --git a/app/hooks/useBannerGestureRootPosition.test.ts b/app/hooks/useBannerGestureRootPosition.test.ts deleted file mode 100644 index c4ea4d710..000000000 --- a/app/hooks/useBannerGestureRootPosition.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {renderHook} from '@testing-library/react-hooks'; -import {Platform} from 'react-native'; - -import { - FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS, - FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS, - FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET, - TABLET_SIDEBAR_WIDTH, -} from '@constants/view'; -import * as Device from '@hooks/device'; - -import {useBannerGestureRootPosition, testExports} from './useBannerGestureRootPosition'; - -jest.mock('@hooks/device', () => ({ - useIsTablet: jest.fn(), - useKeyboardHeight: jest.fn(), - useWindowDimensions: jest.fn(), -})); - -describe('useBannerGestureRootPosition', () => { - const {BANNER_TABLET_WIDTH_PERCENTAGE} = testExports; - const mockUseIsTablet = jest.mocked(Device.useIsTablet); - const mockUseKeyboardHeight = jest.mocked(Device.useKeyboardHeight); - const mockUseWindowDimensions = jest.mocked(Device.useWindowDimensions); - - beforeEach(() => { - jest.clearAllMocks(); - mockUseIsTablet.mockReturnValue(false); - mockUseKeyboardHeight.mockReturnValue(0); - mockUseWindowDimensions.mockReturnValue({width: 375, height: 812}); - }); - - describe('top position', () => { - it('should return correct style for top banner on phone', () => { - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 40, - }), - ); - - expect(result.current).toEqual({ - height: 40, - top: 0, - }); - }); - - it('should return correct style for top banner on tablet', () => { - mockUseIsTablet.mockReturnValue(true); - mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768}); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 40, - }), - ); - - const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH; - const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth; - - expect(result.current).toEqual({ - height: 40, - top: 0, - maxWidth: expectedMaxWidth, - alignSelf: 'center', - }); - }); - }); - - describe('bottom position - iOS', () => { - beforeEach(() => { - Platform.OS = 'ios'; - }); - - it('should return correct style for bottom banner on phone without keyboard', () => { - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS, - }); - }); - - it('should return correct style for bottom banner on phone with keyboard', () => { - mockUseKeyboardHeight.mockReturnValue(300); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300, - }); - }); - - it('should return correct style for bottom banner on tablet without keyboard', () => { - mockUseIsTablet.mockReturnValue(true); - mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768}); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH; - const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth; - const expectedBottom = FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS + FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET; - - expect(result.current).toEqual({ - height: 40, - bottom: expectedBottom, - maxWidth: expectedMaxWidth, - alignSelf: 'center', - }); - }); - - it('should return correct style for bottom banner on tablet with keyboard', () => { - mockUseIsTablet.mockReturnValue(true); - mockUseKeyboardHeight.mockReturnValue(300); - mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768}); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH; - const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth; - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300, - maxWidth: expectedMaxWidth, - alignSelf: 'center', - }); - }); - }); - - describe('bottom position - Android', () => { - beforeEach(() => { - Platform.OS = 'android'; - }); - - it('should return correct style for bottom banner on phone', () => { - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - }); - }); - - it('should ignore keyboard height on Android', () => { - mockUseKeyboardHeight.mockReturnValue(300); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - }); - }); - - it('should return correct style for bottom banner on tablet', () => { - mockUseIsTablet.mockReturnValue(true); - mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768}); - - const {result} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH; - const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth; - - expect(result.current).toEqual({ - height: 40, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - maxWidth: expectedMaxWidth, - alignSelf: 'center', - }); - }); - }); - - describe('container height variations', () => { - it('should handle different container heights', () => { - const {result: result1} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 100, - }), - ); - - expect(result1.current.height).toBe(100); - - const {result: result2} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 200, - }), - ); - - expect(result2.current.height).toBe(200); - }); - }); - - describe('memoization', () => { - it('should memoize result when dependencies do not change', () => { - mockUseIsTablet.mockReturnValue(false); - mockUseKeyboardHeight.mockReturnValue(0); - mockUseWindowDimensions.mockReturnValue({width: 375, height: 812}); - - const {result, rerender} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 40, - }), - ); - - const firstResult = result.current; - rerender(); - const secondResult = result.current; - - expect(firstResult).toBe(secondResult); - }); - - it('should recompute when keyboard height changes', () => { - Platform.OS = 'ios'; - mockUseKeyboardHeight.mockReturnValue(0); - - const {result, rerender} = renderHook(() => - useBannerGestureRootPosition({ - position: 'bottom', - containerHeight: 40, - }), - ); - - const firstResult = result.current; - - mockUseKeyboardHeight.mockReturnValue(300); - rerender(); - const secondResult = result.current; - - expect(firstResult).not.toBe(secondResult); - expect(secondResult.bottom).toBe(FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300); - }); - - it('should recompute when tablet state changes', () => { - mockUseIsTablet.mockReturnValue(false); - mockUseWindowDimensions.mockReturnValue({width: 375, height: 812}); - - const {result, rerender} = renderHook(() => - useBannerGestureRootPosition({ - position: 'top', - containerHeight: 40, - }), - ); - - const firstResult = result.current; - - mockUseIsTablet.mockReturnValue(true); - mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768}); - rerender(); - const secondResult = result.current; - - expect(firstResult).not.toBe(secondResult); - expect(secondResult.maxWidth).toBeDefined(); - expect(secondResult.alignSelf).toBe('center'); - }); - }); -}); - diff --git a/app/hooks/useBannerGestureRootPosition.ts b/app/hooks/useBannerGestureRootPosition.ts deleted file mode 100644 index 9912be3ea..000000000 --- a/app/hooks/useBannerGestureRootPosition.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useMemo} from 'react'; -import {Platform, type ViewStyle} from 'react-native'; - -import { - FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS, - FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID, - FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS, - FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET, - TABLET_SIDEBAR_WIDTH, -} from '@constants/view'; -import {useIsTablet, useKeyboardHeight, useWindowDimensions} from '@hooks/device'; - -import type {FloatingBannerPosition} from '@components/floating_banner/types'; - -const BANNER_TABLET_WIDTH_PERCENTAGE = 96; - -type UseBannerGestureRootPositionParams = { - position: FloatingBannerPosition; - containerHeight: number; -}; - -export const useBannerGestureRootPosition = ({position, containerHeight}: UseBannerGestureRootPositionParams): ViewStyle => { - const isTablet = useIsTablet(); - const keyboardHeight = useKeyboardHeight(); - const {width: windowWidth} = useWindowDimensions(); - const isTop = position === 'top'; - const baseBottomOffset = Platform.OS === 'android' ? FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID : FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS; - const bottomOffset = isTablet ? (baseBottomOffset + FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET) : baseBottomOffset; - - return useMemo(() => { - const baseStyle: ViewStyle = {height: containerHeight}; - - if (isTablet) { - const diffWidth = windowWidth - TABLET_SIDEBAR_WIDTH; - const tabletWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth; - baseStyle.maxWidth = tabletWidth; - baseStyle.alignSelf = 'center'; - } - - if (isTop) { - return {...baseStyle, top: 0}; - } - - if (Platform.OS === 'android') { - return { - ...baseStyle, - bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID, - }; - } - - return { - ...baseStyle, - bottom: (keyboardHeight > 0 ? FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS : bottomOffset) + keyboardHeight, - }; - }, [isTop, keyboardHeight, bottomOffset, containerHeight, isTablet, windowWidth]); -}; - -export const testExports = { - BANNER_TABLET_WIDTH_PERCENTAGE, -}; diff --git a/app/init/app.ts b/app/init/app.ts index 8610f5ced..bb4362299 100644 --- a/app/init/app.ts +++ b/app/init/app.ts @@ -3,13 +3,11 @@ import {CallsManager} from '@calls/calls_manager'; import DatabaseManager from '@database/manager'; -import {getAllServerCredentials, getActiveServerUrl} from '@init/credentials'; +import {getAllServerCredentials} from '@init/credentials'; import {initialLaunch} from '@init/launch'; import ManagedApp from '@init/managed_app'; import PushNotifications from '@init/push_notifications'; import GlobalEventHandler from '@managers/global_event_handler'; -import NetworkConnectivityManager from '@managers/network_connectivity_manager'; -import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager'; import NetworkManager from '@managers/network_manager'; import SessionManager from '@managers/session_manager'; import WebsocketManager from '@managers/websocket_manager'; @@ -51,9 +49,6 @@ export async function initialize() { ManagedApp.init(); SessionManager.init(); CallsManager.initialize(); - - const activeServerUrl = await getActiveServerUrl(); - NetworkConnectivityManager.init(activeServerUrl || null); } } @@ -72,8 +67,5 @@ export async function start() { await WebsocketManager.init(serverCredentials); - NetworkConnectivitySubscriptionManager.init(); - initialLaunch(); } - diff --git a/app/init/launch.test.ts b/app/init/launch.test.ts index 154e431a5..2142f37de 100644 --- a/app/init/launch.test.ts +++ b/app/init/launch.test.ts @@ -53,15 +53,7 @@ jest.mock('@database/manager', () => ({ serverDatabases: {}, })); jest.mock('@init/credentials'); -jest.mock('@queries/app/global', () => { - const {of: mockOf} = require('rxjs'); - return { - getLastViewedChannelIdAndServer: jest.fn(), - getOnboardingViewed: jest.fn(), - getLastViewedThreadIdAndServer: jest.fn(), - observeLowConnectivityMonitor: jest.fn(() => mockOf(true)), - }; -}); +jest.mock('@queries/app/global'); jest.mock('@queries/app/servers'); jest.mock('@queries/servers/post'); jest.mock('@queries/servers/preference'); diff --git a/app/managers/banner_manager.test.ts b/app/managers/banner_manager.test.ts deleted file mode 100644 index 02467bde6..000000000 --- a/app/managers/banner_manager.test.ts +++ /dev/null @@ -1,693 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Navigation} from 'react-native-navigation'; - -import {Screens} from '@constants'; -import {showOverlay, dismissOverlay} from '@screens/navigation'; - -import {BannerManager, testExports} from './banner_manager'; - -import type {FloatingBannerConfig} from '@components/floating_banner/types'; - -interface MockOverlayProps { - banners: FloatingBannerConfig[]; - onDismiss: (id: string) => void; -} - -jest.mock('react-native-navigation', () => ({ - Navigation: { - updateProps: jest.fn(), - }, -})); - -jest.mock('@screens/navigation', () => ({ - showOverlay: jest.fn(), - dismissOverlay: jest.fn().mockResolvedValue(undefined), -})); - -jest.mock('@utils/datetime', () => ({ - toMilliseconds: jest.fn().mockImplementation(({seconds}) => seconds * 1000), -})); - -const mockShowOverlay = jest.mocked(showOverlay); -const mockDismissOverlay = jest.mocked(dismissOverlay); -const mockUpdateProps = jest.mocked(Navigation.updateProps); - -describe('BannerManager', () => { - let timeoutId = 0; - type TimeoutCallback = () => void; - const timeoutCallbacks = new Map(); - - const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: TimeoutCallback) => { - timeoutId++; - timeoutCallbacks.set(timeoutId, cb); - return timeoutId as unknown as NodeJS.Timeout; - }); - - const mockClearTimeout = jest.spyOn(global, 'clearTimeout').mockImplementation((id: NodeJS.Timeout) => { - timeoutCallbacks.delete(id as unknown as number); - }); - - const runAllTimers = () => { - const callbacks = Array.from(timeoutCallbacks.values()); - timeoutCallbacks.clear(); - callbacks.forEach((cb) => cb()); - }; - - beforeEach(() => { - jest.clearAllMocks(); - timeoutCallbacks.clear(); - timeoutId = 0; - BannerManager.cleanup(); - }); - - afterEach(() => { - timeoutCallbacks.clear(); - BannerManager.cleanup(); - }); - - describe('singleton pattern', () => { - it('should export the same instance', () => { - expect(BannerManager).toBeDefined(); - expect(BannerManager).toBe(BannerManager); - }); - - it('should allow creating new instances via testExports', () => { - const newInstance = new testExports.BannerManager(); - expect(newInstance).toBeDefined(); - expect(newInstance).not.toBe(BannerManager); - }); - }); - - describe('showBanner', () => { - const mockFloatingBannerConfig: FloatingBannerConfig = { - id: 'test-banner', - title: 'Test Title', - message: 'Test Message', - type: 'info', - }; - - it('should show banner with correct overlay configuration', async () => { - BannerManager.showBanner(mockFloatingBannerConfig); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalledWith( - Screens.FLOATING_BANNER, - { - banners: [expect.objectContaining({ - id: 'test-banner', - title: 'Test Title', - message: 'Test Message', - type: 'info', - })], - onDismiss: expect.any(Function), - }, - { - overlay: { - interceptTouchOutside: false, - }, - }, - 'floating-banner-overlay', - ); - }); - - it('should show banner overlay when showing banner', async () => { - BannerManager.showBanner(mockFloatingBannerConfig); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalledWith( - 'FloatingBanner', - expect.objectContaining({ - banners: expect.arrayContaining([ - expect.objectContaining({id: 'test-banner'}), - ]), - }), - expect.any(Object), - 'floating-banner-overlay', - ); - }); - - it('should show banner without title and message when using customComponent', async () => { - const bannerWithCustomComponent: FloatingBannerConfig = { - id: 'custom-banner', - customComponent: React.createElement('div', {testID: 'custom-content'}, 'Custom Content'), - }; - - BannerManager.showBanner(bannerWithCustomComponent); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalledWith( - Screens.FLOATING_BANNER, - expect.objectContaining({ - banners: [expect.objectContaining({ - id: 'custom-banner', - customComponent: expect.anything(), - })], - }), - expect.anything(), - 'floating-banner-overlay', - ); - }); - - it('should stack multiple banners and update overlay', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-banner', - title: 'First', - message: 'First message', - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-banner', - title: 'Second', - message: 'Second message', - }; - - BannerManager.showBanner(firstBanner); - await Promise.resolve(); - expect(mockShowOverlay).toHaveBeenCalledTimes(1); - - BannerManager.showBanner(secondBanner); - await Promise.resolve(); - - expect(mockUpdateProps).toHaveBeenCalledWith( - 'floating-banner-overlay', - expect.objectContaining({ - banners: expect.arrayContaining([ - expect.objectContaining({id: 'first-banner'}), - expect.objectContaining({id: 'second-banner'}), - ]), - }), - ); - }); - - it('should handle custom content with React elements', async () => { - const customElement = React.createElement('div', {}, 'Custom content'); - const bannerWithCustomComponent: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - customComponent: customElement, - }; - - jest.spyOn(React, 'isValidElement').mockReturnValue(true); - jest.spyOn(React, 'cloneElement').mockReturnValue(customElement); - - BannerManager.showBanner(bannerWithCustomComponent); - await Promise.resolve(); - - expect(React.cloneElement).toHaveBeenCalledWith( - customElement, - { - onDismiss: expect.any(Function), - dismissible: undefined, - }, - ); - }); - - it('should call cloned onDismiss handler when custom component is dismissed', async () => { - const mockOnDismiss = jest.fn(); - const customElement = React.createElement('div', {}, 'Custom content'); - const bannerWithCustomComponent: FloatingBannerConfig = { - id: 'custom-component-banner', - customComponent: customElement, - dismissible: true, - onDismiss: mockOnDismiss, - }; - - jest.spyOn(React, 'isValidElement').mockReturnValue(true); - - BannerManager.showBanner(bannerWithCustomComponent); - await Promise.resolve(); - - const cloneCall = (React.cloneElement as jest.Mock).mock.calls[0]; - const clonedProps = cloneCall[1]; - const clonedOnDismiss = clonedProps.onDismiss; - - clonedOnDismiss(); - await Promise.resolve(); - - expect(mockOnDismiss).toHaveBeenCalled(); - - runAllTimers(); - await Promise.resolve(); - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should respect dismissible property from banner config', async () => { - const customElement = React.createElement('div', {}, 'Custom content'); - - const dismissibleBanner: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - customComponent: customElement, - dismissible: true, - }; - - const nonDismissibleBanner: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - customComponent: customElement, - dismissible: false, - }; - - jest.spyOn(React, 'isValidElement').mockReturnValue(true); - jest.spyOn(React, 'cloneElement').mockReturnValue(customElement); - - BannerManager.showBanner(dismissibleBanner); - await Promise.resolve(); - expect(React.cloneElement).toHaveBeenCalledWith( - customElement, - { - onDismiss: expect.any(Function), - dismissible: true, - }, - ); - - jest.clearAllMocks(); - - BannerManager.showBanner(nonDismissibleBanner); - await Promise.resolve(); - expect(React.cloneElement).toHaveBeenCalledWith( - customElement, - { - onDismiss: expect.any(Function), - dismissible: false, - }, - ); - }); - - it('should call original onDismiss when banner is dismissed via handleDismiss', async () => { - const mockOnDismiss = jest.fn(); - const bannerWithOnDismiss: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - onDismiss: mockOnDismiss, - }; - - BannerManager.showBanner(bannerWithOnDismiss); - await Promise.resolve(); - - const overlayCall = mockShowOverlay.mock.calls[0]; - const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss; - - overlayOnDismiss?.('test-banner'); - await Promise.resolve(); - - expect(mockOnDismiss).toHaveBeenCalled(); - - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should remove banner from list when dismissed but keep overlay for other banners', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-banner', - title: 'First', - message: 'First message', - onDismiss: jest.fn(), - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-banner', - title: 'Second', - message: 'Second message', - }; - - BannerManager.showBanner(firstBanner); - await Promise.resolve(); - BannerManager.showBanner(secondBanner); - await Promise.resolve(); - - const overlayCall = mockUpdateProps.mock.calls[0]; - const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss; - - overlayOnDismiss?.('first-banner'); - await Promise.resolve(); - - expect(firstBanner.onDismiss).toHaveBeenCalled(); - expect(mockUpdateProps).toHaveBeenCalledWith( - 'floating-banner-overlay', - expect.objectContaining({ - banners: expect.arrayContaining([ - expect.objectContaining({id: 'second-banner'}), - ]), - }), - ); - expect(mockDismissOverlay).not.toHaveBeenCalled(); - }); - - it('should not call onDismiss for different banner IDs in overlay callback', () => { - const mockOnDismiss = jest.fn(); - const bannerWithOnDismiss: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - onDismiss: mockOnDismiss, - }; - - BannerManager.showBanner(bannerWithOnDismiss); - - const overlayCall = mockShowOverlay.mock.calls[0]; - const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss; - - overlayOnDismiss?.('different-banner-id'); - - expect(mockOnDismiss).not.toHaveBeenCalled(); - expect(mockDismissOverlay).not.toHaveBeenCalled(); - }); - - it('should handle errors in onDismiss callback gracefully', async () => { - const mockOnDismiss = jest.fn().mockImplementation(() => { - throw new Error('Test error'); - }); - const bannerWithErrorOnDismiss: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - onDismiss: mockOnDismiss, - }; - - BannerManager.showBanner(bannerWithErrorOnDismiss); - await Promise.resolve(); - - const overlayCall = mockShowOverlay.mock.calls[0]; - const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss; - - expect(() => overlayOnDismiss?.('test-banner')).not.toThrow(); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - }); - - describe('showBannerWithAutoHide', () => { - const mockFloatingBannerConfig: FloatingBannerConfig = { - id: 'auto-hide-banner', - title: 'Auto Hide Banner', - message: 'This will auto hide', - }; - - it('should show banner and set timeout for auto hide with default duration', async () => { - BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000); - }); - - it('should show banner and set timeout for auto hide with custom duration', async () => { - const customDuration = 3000; - - BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig, customDuration); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), customDuration); - }); - - it('should hide banner when timeout executes', async () => { - BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - - runAllTimers(); - await Promise.resolve(); - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - }); - - describe('hideBanner', () => { - const mockFloatingBannerConfig: FloatingBannerConfig = { - id: 'hide-test-banner', - title: 'Hide Test', - message: 'Test hiding', - }; - - it('should hide visible banner and clear timeouts', async () => { - BannerManager.showBanner(mockFloatingBannerConfig); - await Promise.resolve(); - expect(mockShowOverlay).toHaveBeenCalled(); - - BannerManager.hideBanner('hide-test-banner'); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should hide specific banner by ID', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-banner', - title: 'First', - message: 'First message', - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-banner', - title: 'Second', - message: 'Second message', - }; - - BannerManager.showBanner(firstBanner); - BannerManager.showBanner(secondBanner); - await Promise.resolve(); - - BannerManager.hideBanner('first-banner'); - await Promise.resolve(); - - expect(mockUpdateProps).toHaveBeenCalledWith( - 'floating-banner-overlay', - expect.objectContaining({ - banners: expect.arrayContaining([ - expect.objectContaining({id: 'second-banner'}), - ]), - }), - ); - expect(mockDismissOverlay).not.toHaveBeenCalled(); - }); - - it('should call current onDismiss callback when hiding', () => { - const mockOnDismiss = jest.fn(); - const bannerWithOnDismiss: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - onDismiss: mockOnDismiss, - }; - - BannerManager.showBanner(bannerWithOnDismiss); - BannerManager.hideBanner('hide-test-banner'); - - expect(mockOnDismiss).toHaveBeenCalled(); - }); - - it('should handle errors in onDismiss callback during hide', async () => { - const mockOnDismiss = jest.fn().mockImplementation(() => { - throw new Error('Hide error'); - }); - const bannerWithErrorOnDismiss: FloatingBannerConfig = { - ...mockFloatingBannerConfig, - onDismiss: mockOnDismiss, - }; - - BannerManager.showBanner(bannerWithErrorOnDismiss); - await Promise.resolve(); - - expect(() => BannerManager.hideBanner('hide-test-banner')).not.toThrow(); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - - expect(mockOnDismiss).toHaveBeenCalled(); - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - }); - - describe('hideAllBanners', () => { - it('should hide all banners at once', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-banner', - title: 'First', - message: 'First message', - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-banner', - title: 'Second', - message: 'Second message', - }; - - const thirdBanner: FloatingBannerConfig = { - id: 'third-banner', - title: 'Third', - message: 'Third message', - }; - - BannerManager.showBanner(firstBanner); - BannerManager.showBanner(secondBanner); - BannerManager.showBanner(thirdBanner); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - - BannerManager.hideAllBanners(); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should clear all auto-hide timers when hiding all banners', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-auto-hide', - title: 'First Auto Hide', - message: 'First message', - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-auto-hide', - title: 'Second Auto Hide', - message: 'Second message', - }; - - BannerManager.showBannerWithAutoHide(firstBanner, 3000); - BannerManager.showBannerWithAutoHide(secondBanner, 5000); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - - const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length; - BannerManager.hideAllBanners(); - await Promise.resolve(); - - expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore); - - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should do nothing when no banners are visible', () => { - BannerManager.hideAllBanners(); - - expect(mockDismissOverlay).not.toHaveBeenCalled(); - }); - }); - - describe('edge cases', () => { - it('should set empty banners array when hiding last banner', async () => { - const banner: FloatingBannerConfig = { - id: 'single-banner', - title: 'Single', - message: 'Single banner', - }; - - BannerManager.showBanner(banner); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - - BannerManager.hideBanner('single-banner'); - await Promise.resolve(); - - expect(mockUpdateProps).toHaveBeenCalledWith( - 'floating-banner-overlay', - expect.objectContaining({ - banners: [], - onDismiss: expect.any(Function), - }), - ); - - const updatePropsCall = mockUpdateProps.mock.calls[mockUpdateProps.mock.calls.length - 1]; - const emptyOnDismiss = (updatePropsCall?.[1] as MockOverlayProps)?.onDismiss; - - expect(() => emptyOnDismiss?.('non-existent-banner')).not.toThrow(); - }); - }); - - describe('dismiss delay behavior', () => { - it('should cancel overlay dismiss when new banner is added during delay', async () => { - const firstBanner: FloatingBannerConfig = { - id: 'first-banner', - title: 'First', - message: 'First message', - }; - - const secondBanner: FloatingBannerConfig = { - id: 'second-banner', - title: 'Second', - message: 'Second message', - }; - - BannerManager.showBanner(firstBanner); - await Promise.resolve(); - - const dismissCallsBefore = mockDismissOverlay.mock.calls.length; - - BannerManager.hideBanner('first-banner'); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - - BannerManager.showBanner(secondBanner); - await Promise.resolve(); - - runAllTimers(); - await Promise.resolve(); - - expect(mockDismissOverlay).toHaveBeenCalledTimes(dismissCallsBefore); - expect(mockShowOverlay).toHaveBeenLastCalledWith( - 'FloatingBanner', - expect.objectContaining({ - banners: expect.arrayContaining([ - expect.objectContaining({id: 'second-banner'}), - ]), - }), - expect.any(Object), - 'floating-banner-overlay', - ); - }); - }); - - describe('cleanup', () => { - const mockFloatingBannerConfig: FloatingBannerConfig = { - id: 'cleanup-test-banner', - title: 'Cleanup Test', - message: 'Test cleanup', - }; - - it('should clear all timeouts and hide banner', async () => { - BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig); - await Promise.resolve(); - - const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length; - BannerManager.cleanup(); - - expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore); - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should reset manager state', async () => { - BannerManager.showBanner(mockFloatingBannerConfig); - await Promise.resolve(); - - expect(mockShowOverlay).toHaveBeenCalled(); - - BannerManager.cleanup(); - - expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay'); - }); - }); -}); diff --git a/app/managers/banner_manager.ts b/app/managers/banner_manager.ts deleted file mode 100644 index 3f1befcb1..000000000 --- a/app/managers/banner_manager.ts +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Navigation} from 'react-native-navigation'; - -import {Screens} from '@constants'; -import {showOverlay, dismissOverlay} from '@screens/navigation'; -import {toMilliseconds} from '@utils/datetime'; - -import type {FloatingBannerConfig} from '@components/floating_banner/types'; - -const FLOATING_BANNER_OVERLAY_ID = 'floating-banner-overlay'; -const BANNER_DEFAULT_TIME_TO_HIDE = toMilliseconds({seconds: 5}); -const OVERLAY_DISMISS_DELAY = toMilliseconds({seconds: 2}); - -/** - * BannerManager - Singleton for managing floating banner overlays - * - * This manager handles displaying banners at the top or bottom of the screen using - * React Native Navigation overlays. It supports stacking multiple banners and manages - * their lifecycle including auto-hide timers and user dismissals. - * - * **Architecture:** - * - Uses a single overlay component that renders separate top and bottom banner sections - * - Each section (top/bottom) has its own GestureHandlerRootView for independent gesture handling - * - Supports both top and bottom positioned banners simultaneously without blocking screen interaction - * - Employs a promise-chain queue to handle rapid successive banner updates - * - Implements a 2-second delay before dismissing the overlay when the last banner is removed - * - * **Android Gesture Handling Limitation:** - * On Android, when both top AND bottom banners are displayed simultaneously, only ONE of the - * GestureHandlerRootView instances can properly register touch events. This is a known limitation - * with React Native Gesture Handler on Android when multiple gesture root views exist in overlays. - * - * **Current Behavior:** - * - iOS: Top and bottom banners work correctly together with independent gesture handling - * - Android: If both top and bottom banners appear simultaneously, gestures may not work on one section - * - * **Workaround (Not Yet Implemented):** - * To fully support Android, the implementation should: - * 1. Detect when both top and bottom banners exist on Android - * 2. Prioritize showing banners from one position (e.g., top takes precedence) - * 3. Queue banners from the other position until the primary position is clear - * - * **Previous iOS-Only Solution:** - * The implementation creates TWO separate GestureHandlerRootView instances - one for top - * banners and one for bottom banners. Each is positioned and sized only for its banner content - * using `useBannerGestureRootPosition` hook. On iOS this allows: - * - Top and bottom banners to coexist without blocking the middle of the screen - * - User interactions with app content between the banners - * - Each banner section to handle gestures independently - * - `pointerEvents='box-none'` on each GestureHandlerRootView allows touches to pass through - * non-banner areas to the content underneath - * - * @example - * // Show a simple banner - * BannerManager.showBanner({ - * id: 'network-error', - * title: 'Connection Lost', - * message: 'Reconnecting...', - * type: 'error', - * position: 'top' - * }); - * - * @example - * // Show multiple banners (they will stack) - * BannerManager.showBanner({id: 'banner1', position: 'top', ...}); - * BannerManager.showBanner({id: 'banner2', position: 'bottom', ...}); - * - * @example - * // Show with auto-hide - * BannerManager.showBannerWithAutoHide({ - * id: 'success', - * message: 'Changes saved', - * type: 'success' - * }, 3000); - */ -class BannerManagerSingleton { - private activeBanners: FloatingBannerConfig[] = []; - private overlayVisible = false; - private autoHideTimers: Map = new Map(); - private updateQueue: Promise = Promise.resolve(); - private dismissOverlayTimer: NodeJS.Timeout | null = null; - private dismissOverlayResolve: (() => void) | null = null; - - private clearBannerTimer(bannerId: string) { - const timer = this.autoHideTimers.get(bannerId); - if (timer) { - clearTimeout(timer); - this.autoHideTimers.delete(bannerId); - } - } - - private clearAllTimers() { - this.autoHideTimers.forEach((timer) => clearTimeout(timer)); - this.autoHideTimers.clear(); - } - - private cancelDismissOverlay() { - if (this.dismissOverlayTimer) { - clearTimeout(this.dismissOverlayTimer); - this.dismissOverlayTimer = null; - } - - // Resolve the pending Promise to unblock the queue - if (this.dismissOverlayResolve) { - this.dismissOverlayResolve(); - this.dismissOverlayResolve = null; - } - } - - private removeBannerFromList(bannerId: string) { - const bannerIndex = this.activeBanners.findIndex((b) => b.id === bannerId); - if (bannerIndex >= 0) { - const banner = this.activeBanners[bannerIndex]; - this.activeBanners.splice(bannerIndex, 1); - - // Clear any auto-hide timer for this banner - this.clearBannerTimer(bannerId); - - // Invoke onDismiss callback if it exists - if (banner.onDismiss) { - try { - banner.onDismiss(); - } catch { - // Silent catch to ensure cleanup still runs - } - } - } - } - - private updateOverlay() { - this.updateQueue = this.updateQueue.then(async () => { - if (!this.activeBanners.length) { - if (this.overlayVisible) { - Navigation.updateProps(FLOATING_BANNER_OVERLAY_ID, { - banners: [], - onDismiss: () => { - // No-op: no banners to dismiss - }, - }); - - await new Promise((resolve) => { - this.dismissOverlayResolve = resolve; - this.dismissOverlayTimer = setTimeout(() => { - this.dismissOverlayResolve = null; - resolve(); - }, OVERLAY_DISMISS_DELAY); - }); - - if (!this.activeBanners.length && this.overlayVisible) { - await dismissOverlay(FLOATING_BANNER_OVERLAY_ID); - this.overlayVisible = false; - } - this.dismissOverlayTimer = null; - } - return; - } - - this.cancelDismissOverlay(); - - const handleDismiss = (id: string) => { - this.removeBannerFromList(id); - this.updateOverlay(); - }; - - const bannersWithDismissProps = this.activeBanners.map((banner) => { - if (banner.customComponent && React.isValidElement(banner.customComponent)) { - const props: Partial> = { - onDismiss: () => handleDismiss(banner.id), - dismissible: banner.dismissible, - }; - return { - ...banner, - customComponent: React.cloneElement(banner.customComponent, props), - }; - } - return banner; - }); - - if (this.overlayVisible) { - Navigation.updateProps(FLOATING_BANNER_OVERLAY_ID, { - banners: bannersWithDismissProps, - onDismiss: handleDismiss, - }); - } else { - showOverlay( - Screens.FLOATING_BANNER, - { - banners: bannersWithDismissProps, - onDismiss: handleDismiss, - }, - { - overlay: { - interceptTouchOutside: false, - }, - }, - FLOATING_BANNER_OVERLAY_ID, - ); - this.overlayVisible = true; - } - }); - } - - showBanner(bannerConfig: FloatingBannerConfig) { - this.removeBannerFromList(bannerConfig.id); - - this.activeBanners.push(bannerConfig); - - this.updateOverlay(); - } - - showBannerWithAutoHide(bannerConfig: FloatingBannerConfig, durationMs: number = BANNER_DEFAULT_TIME_TO_HIDE) { - this.showBanner(bannerConfig); - - const timer = setTimeout(() => { - this.hideBanner(bannerConfig.id); - }, durationMs); - - this.autoHideTimers.set(bannerConfig.id, timer); - } - - /** - * Hides a specific banner by ID. - * - * @param bannerId - Required banner ID to hide. This ensures explicit control and prevents - * accidental dismissal of banners from other systems. - * - * @example - * BannerManager.hideBanner('network-error'); - * - * @remarks - * If you need to clear all banners, use {@link hideAllBanners} instead. - */ - hideBanner(bannerId: string) { - this.removeBannerFromList(bannerId); - this.updateOverlay(); - } - - hideAllBanners() { - this.clearAllTimers(); - this.activeBanners = []; - this.updateOverlay(); - } - - cleanup() { - this.clearAllTimers(); - this.cancelDismissOverlay(); - this.activeBanners = []; - if (this.overlayVisible) { - dismissOverlay(FLOATING_BANNER_OVERLAY_ID); - this.overlayVisible = false; - } - } - -} - -export const BannerManager = new BannerManagerSingleton(); - -export const testExports = { - BannerManager: BannerManagerSingleton, -}; diff --git a/app/managers/network_connectivity_manager.test.ts b/app/managers/network_connectivity_manager.test.ts deleted file mode 100644 index d5a86c6b0..000000000 --- a/app/managers/network_connectivity_manager.test.ts +++ /dev/null @@ -1,717 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {BannerManager} from './banner_manager'; -import NetworkConnectivityManager, {testExports} from './network_connectivity_manager'; - -const { - shouldShowDisconnectedBanner, - shouldShowConnectingBanner, - shouldShowPerformanceBanner, - shouldShowReconnectionBanner, -} = testExports; - -// Define constants locally for testing -const FLOATING_BANNER_OVERLAY_ID = 'floating-banner-overlay'; -const TIME_TO_OPEN = 1000; -const TIME_TO_CLOSE = 5000; - -const mockBannerManager = BannerManager as jest.Mocked; - -jest.mock('./banner_manager', () => ({ - BannerManager: { - showBanner: jest.fn(), - showBannerWithAutoHide: jest.fn(), - showBannerWithDelay: jest.fn(), - hideBanner: jest.fn(), - cleanup: jest.fn(), - getCurrentBannerId: jest.fn().mockReturnValue(null), - isBannerVisible: jest.fn().mockReturnValue(false), - }, -})); - -function setupConnectedState(manager: typeof NetworkConnectivityManager) { - manager.updateState('connected', {isInternetReachable: true}, 'active'); -} - -function setupReconnectionScenario(manager: typeof NetworkConnectivityManager) { - setupConnectedState(manager); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); -} - -describe('NetworkConnectivityManager', () => { - let manager: typeof NetworkConnectivityManager; - const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void) => { - cb(); - return 1 as unknown as NodeJS.Timeout; - }); - jest.spyOn(global, 'clearTimeout').mockImplementation(() => undefined); - - beforeEach(() => { - mockSetTimeout.mockClear(); - Object.values(mockBannerManager).forEach((mock) => { - if (typeof mock === 'function' && 'mockClear' in mock) { - mock.mockClear(); - } - }); - manager = NetworkConnectivityManager; - - manager.cleanup(); - manager.setServerConnectionStatus(false, null); - }); - - describe('singleton pattern', () => { - it('should return the same instance', () => { - const instance1 = NetworkConnectivityManager; - const instance2 = NetworkConnectivityManager; - expect(instance1).toBe(instance2); - }); - }); - - describe('initialization', () => { - it('should initialize with server URL', () => { - const serverUrl = 'https://test.server.com'; - manager.init(serverUrl); - - expect((manager as any).currentServerUrl).toBe(serverUrl); - }); - - it('should initialize with null server URL', () => { - manager.init(null); - - expect((manager as any).currentServerUrl).toBeNull(); - }); - - it('should reset state on initialization', () => { - manager.setServerConnectionStatus(true, 'https://old.server.com'); - setupReconnectionScenario(manager); - - manager.init('https://new.server.com'); - - expect((manager as any).currentServerUrl).toBe('https://new.server.com'); - expect((manager as any).isOnAppStart).toBe(true); - }); - }); - - describe('shutdown', () => { - it('should completely reset all state', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - setupReconnectionScenario(manager); - - manager.shutdown(); - - expect((manager as any).currentServerUrl).toBeNull(); - expect((manager as any).websocketState).toBeNull(); - expect((manager as any).netInfo).toBeNull(); - expect((manager as any).appState).toBeNull(); - expect((manager as any).currentPerformanceState).toBeNull(); - expect((manager as any).suppressSlowPerformanceBanner).toBe(false); - expect((manager as any).isOnAppStart).toBe(true); - }); - - it('should call cleanup during shutdown', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.shutdown(); - - expect(mockBannerManager.cleanup).toHaveBeenCalled(); - }); - }); - - describe('server connection status', () => { - it('should hide banner when server is disconnected', () => { - manager.setServerConnectionStatus(false, null); - manager.updateState('not_connected', {isInternetReachable: false}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should show banner when server is connected after first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - setupReconnectionScenario(manager); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).toHaveBeenCalled(); - }); - - it('should hide banner when server URL is empty', () => { - manager.setServerConnectionStatus(true, ''); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should hide banner when server URL is null', () => { - manager.setServerConnectionStatus(true, null); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - }); - - describe('websocket state handling', () => { - beforeEach(() => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - }); - - it('should not show banner when connecting on first connection', () => { - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should not show banner when not connected on first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - expect(mockSetTimeout).not.toHaveBeenCalled(); - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should show banner when connecting after first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - setupReconnectionScenario(manager); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).toHaveBeenCalled(); - }); - - it('should show banner with auto-hide when transitioning to connected', () => { - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - expect(mockBannerManager.showBanner).toHaveBeenCalled(); - - mockBannerManager.showBanner.mockClear(); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - }); - }); - - describe('app state handling', () => { - beforeEach(() => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - }); - - it('should hide banner when app goes to background', () => { - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - expect(mockBannerManager.showBanner).toHaveBeenCalled(); - - mockBannerManager.hideBanner.mockClear(); - manager.updateState('not_connected', {isInternetReachable: true}, 'background'); - - expect(mockBannerManager.hideBanner).toHaveBeenCalled(); - }); - - it('should show banner when app becomes active and not connected after first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).toHaveBeenCalled(); - }); - }); - - describe('cleanup', () => { - it('should hide banner and cleanup on cleanup', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - manager.cleanup(); - - expect(mockBannerManager.cleanup).toHaveBeenCalled(); - }); - }); - - describe('connection restoration behavior', () => { - it('should not show banner on initial connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - expect((manager as any).previousWebsocketState).toBeNull(); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should show banner when transitioning from disconnected to connected after first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - }); - - it('should not show banner when transitioning from connecting to connected on first connection', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - mockBannerManager.showBanner.mockClear(); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled(); - }); - - it('should not show banner when staying connected', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - mockBannerManager.showBanner.mockClear(); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - }); - - describe('constants', () => { - it('should have correct timeout values', () => { - expect(TIME_TO_OPEN).toBe(1000); - expect(TIME_TO_CLOSE).toBe(5000); - }); - - it('should have correct overlay ID', () => { - expect(FLOATING_BANNER_OVERLAY_ID).toBe('floating-banner-overlay'); - }); - }); - - describe('null state handling', () => { - it('should return status unknown when websocketState is null', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - (manager as any).websocketState = null; - (manager as any).netInfo = {isInternetReachable: true}; - - const message = (manager as any).getConnectionMessage(); - - expect(message).toBe('Connection status unknown'); - }); - - it('should return status unknown when netInfo is null', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - (manager as any).websocketState = 'connected'; - (manager as any).netInfo = null; - - const message = (manager as any).getConnectionMessage(); - - expect(message).toBe('Connection status unknown'); - }); - }); - - describe('pure functions', () => { - const {getConnectionMessageText, isReconnection} = testExports; - - describe('getConnectionMessageText', () => { - const mockFormatMessage = jest.fn((descriptor) => descriptor.defaultMessage); - - beforeEach(() => { - mockFormatMessage.mockClear(); - }); - - it('should return connected message when websocket is connected', () => { - const result = getConnectionMessageText('connected', true, mockFormatMessage); - expect(result).toBe('Connection restored'); - expect(mockFormatMessage).toHaveBeenCalledWith({ - id: 'connection_banner.connected', - defaultMessage: 'Connection restored', - }); - }); - - it('should return connecting message when websocket is connecting', () => { - const result = getConnectionMessageText('connecting', true, mockFormatMessage); - expect(result).toBe('Connecting...'); - expect(mockFormatMessage).toHaveBeenCalledWith({ - id: 'connection_banner.connecting', - defaultMessage: 'Connecting...', - }); - }); - - it('should return not reachable message when internet is reachable but not connected', () => { - const result = getConnectionMessageText('not_connected', true, mockFormatMessage); - expect(result).toBe('The server is not reachable'); - expect(mockFormatMessage).toHaveBeenCalledWith({ - id: 'connection_banner.not_reachable', - defaultMessage: 'The server is not reachable', - }); - }); - - it('should return not connected message when internet is not reachable', () => { - const result = getConnectionMessageText('not_connected', false, mockFormatMessage); - expect(result).toBe('Unable to connect to network'); - expect(mockFormatMessage).toHaveBeenCalledWith({ - id: 'connection_banner.not_connected', - defaultMessage: 'Unable to connect to network', - }); - }); - }); - - describe('isReconnection', () => { - it('should return true when previous state was not_connected and not first connection', () => { - const result = isReconnection('not_connected', false); - expect(result).toBe(true); - }); - - it('should return true when previous state was connecting and not first connection', () => { - const result = isReconnection('connecting', false); - expect(result).toBe(true); - }); - - it('should return false when previous state was connected', () => { - const result = isReconnection('connected', false); - expect(result).toBe(false); - }); - - it('should return false when it is first connection regardless of previous state', () => { - expect(isReconnection('not_connected', true)).toBe(false); - expect(isReconnection('connecting', true)).toBe(false); - expect(isReconnection('connected', true)).toBe(false); - }); - - it('should return true when previous state is null and not first connection', () => { - const result = isReconnection(null, false); - expect(result).toBe(true); - }); - }); - - describe('shouldShowDisconnectedBanner', () => { - it('should return true when websocket is not_connected and not first connection', () => { - const result = shouldShowDisconnectedBanner('not_connected', false); - expect(result).toBe(true); - }); - - it('should return false when websocket is not_connected but is first connection', () => { - const result = shouldShowDisconnectedBanner('not_connected', true); - expect(result).toBe(false); - }); - - it('should return false when websocket is connected', () => { - const result = shouldShowDisconnectedBanner('connected', false); - expect(result).toBe(false); - }); - - it('should return false when websocket is connecting', () => { - const result = shouldShowDisconnectedBanner('connecting', false); - expect(result).toBe(false); - }); - - it('should return false when websocket state is null', () => { - const result = shouldShowDisconnectedBanner(null, false); - expect(result).toBe(false); - }); - }); - - describe('shouldShowConnectingBanner', () => { - it('should return true when websocket is connecting and not first connection', () => { - const result = shouldShowConnectingBanner('connecting', false); - expect(result).toBe(true); - }); - - it('should return false when websocket is connecting but is first connection', () => { - const result = shouldShowConnectingBanner('connecting', true); - expect(result).toBe(false); - }); - - it('should return false when websocket is connected', () => { - const result = shouldShowConnectingBanner('connected', false); - expect(result).toBe(false); - }); - - it('should return false when websocket is not_connected', () => { - const result = shouldShowConnectingBanner('not_connected', false); - expect(result).toBe(false); - }); - - it('should return false when websocket state is null', () => { - const result = shouldShowConnectingBanner(null, false); - expect(result).toBe(false); - }); - }); - - describe('shouldShowPerformanceBanner', () => { - it('should return true when performance is slow and not suppressed', () => { - const result = shouldShowPerformanceBanner('slow', false); - expect(result).toBe(true); - }); - - it('should return false when performance is normal', () => { - const result = shouldShowPerformanceBanner('normal', false); - expect(result).toBe(false); - }); - - it('should return false when performance is suppressed', () => { - const result = shouldShowPerformanceBanner('slow', true); - expect(result).toBe(false); - }); - - it('should return false when performance state is null', () => { - const result = shouldShowPerformanceBanner(null, false); - expect(result).toBe(false); - }); - }); - - describe('shouldShowReconnectionBanner', () => { - it('should return true when all conditions are met for reconnection', () => { - const result = shouldShowReconnectionBanner('connected', 'not_connected', false); - expect(result).toBe(true); - }); - - it('should return true when previous state was connecting', () => { - const result = shouldShowReconnectionBanner('connected', 'connecting', false); - expect(result).toBe(true); - }); - - it('should return false when websocket is not connected', () => { - const result = shouldShowReconnectionBanner('not_connected', 'not_connected', false); - expect(result).toBe(false); - }); - - it('should return false when websocket is connecting', () => { - const result = shouldShowReconnectionBanner('connecting', 'not_connected', false); - expect(result).toBe(false); - }); - - it('should return false when it is first connection', () => { - const result = shouldShowReconnectionBanner('connected', 'not_connected', true); - expect(result).toBe(false); - }); - - it('should return true when reconnection conditions are met', () => { - const result = shouldShowReconnectionBanner('connected', 'not_connected', false); - expect(result).toBe(true); - }); - - it('should return false when previous state was connected', () => { - const result = shouldShowReconnectionBanner('connected', 'connected', false); - expect(result).toBe(false); - }); - - it('should return true when previous state is null and not first connection', () => { - const result = shouldShowReconnectionBanner('connected', null, false); - expect(result).toBe(true); - }); - - it('should return false when websocket state is null', () => { - const result = shouldShowReconnectionBanner(null, 'not_connected', false); - expect(result).toBe(false); - }); - }); - }); - - describe('performance state handling', () => { - beforeEach(() => { - mockSetTimeout.mockClear(); - Object.values(mockBannerManager).forEach((mock) => { - if (typeof mock === 'function' && 'mockClear' in mock) { - mock.mockClear(); - } - }); - manager = NetworkConnectivityManager; - manager.setServerConnectionStatus(true, 'https://test.server.com'); - - jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void, delay: number) => { - if (!delay) { - cb(); - } - return 1 as unknown as NodeJS.Timeout; - }); - }); - - it('should show performance banner when performance is slow', () => { - setupConnectedState(manager); - manager.updatePerformanceState('slow'); - - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - }); - - it('should suppress performance banner until normal when banner is manually dismissed', () => { - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - mockBannerManager.showBannerWithAutoHide.mockClear(); - manager.updatePerformanceState('slow'); - - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - const performanceBannerConfig = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; - expect((performanceBannerConfig.customComponent as any).props.message).toBe('Limited network connection'); - - const onDismiss = performanceBannerConfig.onDismiss; - onDismiss!(); - - mockBannerManager.showBannerWithAutoHide.mockClear(); - mockBannerManager.showBanner.mockClear(); - - manager.updatePerformanceState('slow'); - expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled(); - expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); - }); - - it('should reset suppression when reset is called', () => { - (manager as any).suppressSlowPerformanceBanner = true; - expect((manager as any).suppressSlowPerformanceBanner).toBe(true); - - manager.reset(); - - expect((manager as any).suppressSlowPerformanceBanner).toBe(false); - }); - - it('should hide banner when performance returns to normal', () => { - setupConnectedState(manager); - - manager.updatePerformanceState('slow'); - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - - mockBannerManager.hideBanner.mockClear(); - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updatePerformanceState('normal'); - expect(mockBannerManager.hideBanner).toHaveBeenCalled(); - expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled(); - }); - - it('should not hide banner if it is not a performance banner', () => { - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - mockBannerManager.hideBanner.mockClear(); - - manager.updatePerformanceState('normal'); - expect(mockBannerManager.hideBanner).not.toHaveBeenCalled(); - }); - - it('should maintain performance state when performance becomes slow', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - manager.updatePerformanceState('slow'); - expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); - expect((manager as any).currentPerformanceState).toBe('slow'); - }); - - it('should show banners on first disconnect/reconnect cycle after performance auto-hide', () => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - manager.updatePerformanceState('slow'); - const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; - expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection'); - - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - mockBannerManager.hideBanner.mockClear(); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; - expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - }); - - }); - - describe('banner priority order', () => { - beforeEach(() => { - manager.setServerConnectionStatus(true, 'https://test.server.com'); - setupConnectedState(manager); - }); - - it('should prioritize disconnected over performance', () => { - manager.updatePerformanceState('slow'); - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; - expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); - }); - - it('should prioritize disconnected over connecting', () => { - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - mockBannerManager.showBanner.mockClear(); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; - expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); - }); - - it('should prioritize performance over connecting', () => { - manager.updatePerformanceState('slow'); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; - expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection'); - }); - - it('should prioritize connecting over reconnection', () => { - manager.updatePerformanceState('normal'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('connecting', {isInternetReachable: true}, 'active'); - - const connectingBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; - expect((connectingBannerCall.customComponent as any).props.message).toBe('Connecting...'); - }); - - it('should show reconnection banner only when connected after disconnect', () => { - manager.updatePerformanceState('normal'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; - expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored'); - }); - - it('should hide banner when connected and no performance issues', () => { - manager.updatePerformanceState('normal'); - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - manager.updateState('connected', {isInternetReachable: true}, 'active'); - mockBannerManager.hideBanner.mockClear(); - - manager.updateState('connected', {isInternetReachable: true}, 'active'); - - expect(mockBannerManager.hideBanner).toHaveBeenCalled(); - }); - - it('should handle all conditions simultaneously and show highest priority', () => { - manager.updatePerformanceState('slow'); - mockBannerManager.showBanner.mockClear(); - mockBannerManager.showBannerWithAutoHide.mockClear(); - - manager.updateState('not_connected', {isInternetReachable: true}, 'active'); - - const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; - expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); - }); - }); -}); diff --git a/app/managers/network_connectivity_manager.ts b/app/managers/network_connectivity_manager.ts deleted file mode 100644 index 2565216b0..000000000 --- a/app/managers/network_connectivity_manager.ts +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import ConnectionBanner from '@components/connection_banner/connection_banner'; -import {toMilliseconds} from '@utils/datetime'; -import {getIntlShape} from '@utils/general'; - -import {BannerManager} from './banner_manager'; - -import type {NetworkPerformanceState} from './network_performance_manager'; -import type {FloatingBannerConfig} from '@components/floating_banner/types'; - -const RECONNECTION_BANNER_DURATION = toMilliseconds({seconds: 3}); -const PERFORMANCE_BANNER_DURATION = toMilliseconds({seconds: 10}); -const NETWORK_STATUS_BANNER_ID = 'network-status'; - -function getConnectionMessageText( - websocketState: WebsocketConnectedState, - isInternetReachable: boolean | null, - formatMessage: (descriptor: {id: string; defaultMessage: string}) => string, -): string { - const isConnected = websocketState === 'connected'; - - if (isConnected) { - return formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}); - } - - if (websocketState === 'connecting') { - return formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'}); - } - - if (isInternetReachable) { - return formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'}); - } - - return formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'}); -} - -function isReconnection( - previousWebsocketState: WebsocketConnectedState | null, - isOnAppStart: boolean, -): boolean { - return previousWebsocketState !== 'connected' && !isOnAppStart; -} - -function shouldShowDisconnectedBanner( - websocketState: WebsocketConnectedState | null, - isOnAppStart: boolean, -): boolean { - return websocketState === 'not_connected' && !isOnAppStart; -} - -function shouldShowConnectingBanner( - websocketState: WebsocketConnectedState | null, - isOnAppStart: boolean, -): boolean { - return websocketState === 'connecting' && !isOnAppStart; -} - -function shouldShowPerformanceBanner( - performanceState: NetworkPerformanceState | null, - suppressPerformanceBanner: boolean, -): boolean { - return performanceState === 'slow' && !suppressPerformanceBanner; -} - -function shouldShowReconnectionBanner( - websocketState: WebsocketConnectedState | null, - previousWebsocketState: WebsocketConnectedState | null, - isOnAppStart: boolean, -): boolean { - return websocketState === 'connected' && - isReconnection(previousWebsocketState, isOnAppStart); -} - -class NetworkConnectivityManagerSingleton { - private currentServerUrl: string | null = null; - private websocketState: WebsocketConnectedState | null = null; - private previousWebsocketState: WebsocketConnectedState | null = null; - private isOnAppStart = true; - private netInfo: {isInternetReachable: boolean | null} | null = null; - private appState: string | null = null; - private readonly intl = getIntlShape(); - private currentPerformanceState: NetworkPerformanceState | null = null; - private suppressSlowPerformanceBanner = false; - - private getConnectionMessage(): string { - if (!this.websocketState || !this.netInfo) { - return this.intl.formatMessage({id: 'connection_banner.status_unknown', defaultMessage: 'Connection status unknown'}); - } - - return getConnectionMessageText(this.websocketState, this.netInfo.isInternetReachable, this.intl.formatMessage); - } - - private getPerformanceMessage(): string { - return this.intl.formatMessage({id: 'connection_banner.limited_network_connection', defaultMessage: 'Limited network connection'}); - } - - private showConnectivity(isConnected: boolean, durationMs?: number) { - const message = this.getConnectionMessage(); - const bannerConfig = this.createConnectivityBannerConfig(message, isConnected); - - if (durationMs) { - BannerManager.showBannerWithAutoHide(bannerConfig, durationMs); - } else { - BannerManager.showBanner(bannerConfig); - } - } - - private showPerformance(durationMs: number) { - const message = this.getPerformanceMessage(); - const bannerConfig = this.createPerformanceBannerConfig(message); - BannerManager.showBannerWithAutoHide(bannerConfig, durationMs); - } - - private createConnectivityBannerConfig(message: string, isConnected: boolean): FloatingBannerConfig { - return { - id: NETWORK_STATUS_BANNER_ID, - dismissible: true, - customComponent: React.createElement(ConnectionBanner, { - isConnected, - message, - dismissible: true, - onDismiss: () => { - // Placeholder to enable dismiss button - actual dismissal handled by BannerManager - }, - }), - position: 'bottom', - }; - } - - private createPerformanceBannerConfig(message: string): FloatingBannerConfig { - return { - id: NETWORK_STATUS_BANNER_ID, - dismissible: true, - onDismiss: () => { - this.suppressSlowPerformanceBanner = true; - }, - customComponent: React.createElement(ConnectionBanner, { - isConnected: false, - message, - dismissible: true, - onDismiss: () => { - // Placeholder to enable dismiss button - actual dismissal handled by BannerManager - }, - }), - position: 'bottom', - }; - } - - init(serverUrl: string | null = null) { - this.currentServerUrl = serverUrl; - this.cleanup(); - } - - setServerConnectionStatus(connected: boolean, serverUrl: string | null = null) { - this.currentServerUrl = serverUrl; - - if (!connected) { - BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); - this.websocketState = null; - this.previousWebsocketState = null; - } - } - - updateState( - websocketState: WebsocketConnectedState, - netInfo: {isInternetReachable: boolean | null}, - appState: string, - ) { - this.previousWebsocketState = this.websocketState; - this.websocketState = websocketState; - this.netInfo = netInfo; - this.appState = appState; - - this.updateBanner(); - - if (websocketState === 'connected' && this.isOnAppStart) { - this.isOnAppStart = false; - } - } - - updatePerformanceState( - performanceState: NetworkPerformanceState, - ) { - const wasSlowPerformance = this.currentPerformanceState === 'slow'; - this.currentPerformanceState = performanceState; - - // Performance state changes require special handling to avoid showing reconnection banners. - // We can't always call updateBanner() like updateState() does, because it would re-evaluate - // all banner conditions (including reconnection) when only performance has changed. - - // Show slow performance banner (suppression check here prevents unnecessary updateBanner calls. - // suppressSlowPerformanceBanner also checks suppression for calls coming from updateState). - - if (performanceState === 'slow' && !this.suppressSlowPerformanceBanner) { - this.updateBanner(); - } else if (wasSlowPerformance && performanceState === 'normal') { - BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); - } - } - - reset() { - this.suppressSlowPerformanceBanner = false; - } - - cleanup() { - BannerManager.cleanup(); - this.previousWebsocketState = null; - this.isOnAppStart = true; - this.reset(); - } - - shutdown() { - this.cleanup(); - this.currentServerUrl = null; - this.websocketState = null; - this.netInfo = null; - this.appState = null; - this.currentPerformanceState = null; - } - - private updateBanner() { - if (!this.currentServerUrl || this.appState === 'background') { - BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); - return; - } - - // Banner priority order (highest to lowest) - // 1. Disconnected - critical connection loss - if (this.handleDisconnectedState()) { - return; - } - - // 2. Performance - slow network warning - if (this.handlePerformanceState()) { - return; - } - - // 3. Connecting - attempting to reconnect - if (this.handleConnectingState()) { - return; - } - - // 4. Reconnection - successful reconnection (auto-hide) - if (this.handleReconnectionState()) { - return; - } - - BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); - } - - private handleDisconnectedState(): boolean { - if (shouldShowDisconnectedBanner(this.websocketState, this.isOnAppStart)) { - this.showConnectivity(false); - return true; - } - return false; - } - - private handleConnectingState(): boolean { - if (shouldShowConnectingBanner(this.websocketState, this.isOnAppStart)) { - this.showConnectivity(false); - return true; - } - return false; - } - - private handlePerformanceState(): boolean { - if (shouldShowPerformanceBanner(this.currentPerformanceState, this.suppressSlowPerformanceBanner)) { - this.showPerformance(PERFORMANCE_BANNER_DURATION); - return true; - } - return false; - } - - private handleReconnectionState(): boolean { - if (shouldShowReconnectionBanner( - this.websocketState, - this.previousWebsocketState, - this.isOnAppStart, - )) { - this.showConnectivity(true, RECONNECTION_BANNER_DURATION); - return true; - } - return false; - } -} - -const NetworkConnectivityManager = new NetworkConnectivityManagerSingleton(); - -export default NetworkConnectivityManager; - -export const testExports = { - NetworkConnectivityManager: NetworkConnectivityManagerSingleton, - getConnectionMessageText, - isReconnection, - shouldShowDisconnectedBanner, - shouldShowConnectingBanner, - shouldShowPerformanceBanner, - shouldShowReconnectionBanner, - RECONNECTION_BANNER_DURATION, - PERFORMANCE_BANNER_DURATION, - NETWORK_STATUS_BANNER_ID, -}; diff --git a/app/managers/network_connectivity_subscription_manager.test.ts b/app/managers/network_connectivity_subscription_manager.test.ts deleted file mode 100644 index 534a94677..000000000 --- a/app/managers/network_connectivity_subscription_manager.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import NetInfo from '@react-native-community/netinfo'; -import {AppState} from 'react-native'; -import {Subscription} from 'rxjs'; - -import {subscribeActiveServers} from '@database/subscription/servers'; - -import NetworkConnectivityManager from './network_connectivity_manager'; -import { - startNetworkConnectivitySubscriptions, - stopNetworkConnectivitySubscriptions, - shutdownNetworkConnectivitySubscriptions, -} from './network_connectivity_subscription_manager'; -import NetworkPerformanceManager from './network_performance_manager'; -import WebsocketManager from './websocket_manager'; - -type MockServer = { - url: string; - lastActiveAt: number; -}; - -type MockNetInfoState = { - isInternetReachable: boolean | null; -}; - -// Mock dependencies -jest.mock('@react-native-community/netinfo', () => ({ - addEventListener: jest.fn(), -})); -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(), - }, -})); -jest.mock('@database/subscription/servers', () => ({ - subscribeActiveServers: jest.fn(), -})); -jest.mock('@utils/general', () => ({ - getIntlShape: jest.fn(() => ({ - formatMessage: jest.fn((descriptor) => descriptor.defaultMessage), - })), -})); -jest.mock('./network_connectivity_manager', () => ({ - setServerConnectionStatus: jest.fn(), - updateState: jest.fn(), - updatePerformanceState: jest.fn(), - shutdown: jest.fn(), - reset: jest.fn(), -})); -jest.mock('./websocket_manager', () => ({ - observeWebsocketState: jest.fn(), -})); -jest.mock('./network_performance_manager', () => ({ - observePerformanceState: jest.fn(), -})); - -const mockAppState = AppState as jest.Mocked; -const mockNetInfo = NetInfo as jest.Mocked; -const mockSubscribeActiveServers = subscribeActiveServers as jest.MockedFunction; -const mockNetworkConnectivityManager = NetworkConnectivityManager as jest.Mocked; -const mockNetworkPerformanceManager = NetworkPerformanceManager as jest.Mocked; -const mockWebsocketManager = WebsocketManager as jest.Mocked; - -describe('NetworkConnectivitySubscriptionManager', () => { - const mockWebsocketSubscription = { - unsubscribe: jest.fn(), - } as unknown as Subscription; - - const mockPerformanceSubscription = { - unsubscribe: jest.fn(), - } as unknown as Subscription; - - beforeEach(() => { - jest.clearAllMocks(); - - mockAppState.addEventListener.mockReturnValue({ - remove: jest.fn(), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - mockNetInfo.addEventListener.mockReturnValue(jest.fn()); - - mockWebsocketManager.observeWebsocketState.mockReturnValue({ - subscribe: jest.fn(() => mockWebsocketSubscription), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ - subscribe: jest.fn(() => mockPerformanceSubscription), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - }); - - afterEach(() => { - shutdownNetworkConnectivitySubscriptions(); - }); - - describe('startNetworkConnectivitySubscriptions', () => { - it('should set up app state listener on first call', () => { - startNetworkConnectivitySubscriptions(); - - expect(mockAppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); - }); - - it('should not recreate app state listener on subsequent calls', () => { - startNetworkConnectivitySubscriptions(); - mockAppState.addEventListener.mockClear(); - - startNetworkConnectivitySubscriptions(); - - expect(mockAppState.addEventListener).not.toHaveBeenCalled(); - }); - - it('should set up net info listener', () => { - startNetworkConnectivitySubscriptions(); - - expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should set up active servers subscription', () => { - startNetworkConnectivitySubscriptions(); - - expect(mockSubscribeActiveServers).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should stop subscriptions when going to background and restart when returning to active', () => { - const mockAppStateListener = {remove: jest.fn()}; - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - - mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - - const appStateCallback = mockAppState.addEventListener.mock.calls[0][1]; - - appStateCallback('background'); - - expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); - expect(mockAppStateListener.remove).not.toHaveBeenCalled(); - - mockNetInfo.addEventListener.mockClear(); - mockSubscribeActiveServers.mockClear(); - - appStateCallback('active'); - - expect(mockNetInfo.addEventListener).toHaveBeenCalled(); - expect(mockSubscribeActiveServers).toHaveBeenCalled(); - expect(mockAppStateListener.remove).not.toHaveBeenCalled(); - }); - - it('should not restart subscriptions when app state does not change', () => { - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - - const appStateCallback = mockAppState.addEventListener.mock.calls[0][1]; - - mockNetInfo.addEventListener.mockClear(); - mockSubscribeActiveServers.mockClear(); - - appStateCallback('active'); - - expect(mockNetInfo.addEventListener).not.toHaveBeenCalled(); - expect(mockSubscribeActiveServers).not.toHaveBeenCalled(); - expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled(); - }); - - it('should handle net info changes', () => { - startNetworkConnectivitySubscriptions(); - - const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0]; - const mockNetInfoState: MockNetInfoState = {isInternetReachable: true}; - netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should handle active servers change with no servers', async () => { - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback([] as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(false, null); - expect(mockNetworkConnectivityManager.shutdown).toHaveBeenCalled(); - }); - - it('should handle active servers change with servers', async () => { - const mockServers: MockServer[] = [ - {url: 'https://server1.com', lastActiveAt: 1000}, - {url: 'https://server2.com', lastActiveAt: 2000}, - ]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server2.com'); - expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com'); - expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://server2.com'); - }); - - }); - - describe('stopNetworkConnectivitySubscriptions', () => { - it('should clean up subscriptions but preserve AppState listener', async () => { - const mockAppStateListener = {remove: jest.fn()}; - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()}; - const mockPerformanceSubscriptionForCleanup = {unsubscribe: jest.fn()}; - - mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockWebsocketManager.observeWebsocketState.mockReturnValue({ - subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ - subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - - // Simulate active servers to create websocket subscription - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - stopNetworkConnectivitySubscriptions(); - - expect(mockAppStateListener.remove).not.toHaveBeenCalled(); - expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); - expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); - expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); - }); - - it('should be safe to call multiple times', () => { - const mockAppStateListener = {remove: jest.fn()}; - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - - mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - stopNetworkConnectivitySubscriptions(); - - // Clear mock call counts - mockAppStateListener.remove.mockClear(); - mockNetInfoUnsubscriber.mockClear(); - mockActiveServersUnsubscriber.unsubscribe.mockClear(); - - // Call stop again - should not throw and should handle gracefully - expect(() => stopNetworkConnectivitySubscriptions()).not.toThrow(); - - // Verify cleanup methods are not called again (they should be no-ops) - expect(mockAppStateListener.remove).not.toHaveBeenCalled(); - expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled(); - }); - }); - - describe('websocket subscription handling', () => { - it('should subscribe to websocket state changes', async () => { - const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://test.com'); - }); - - it('should call updateState when websocket state changes', async () => { - let websocketCallback: ((state: string) => void) | undefined; - mockWebsocketManager.observeWebsocketState.mockReturnValue({ - subscribe: jest.fn((callback: (state: string) => void) => { - websocketCallback = callback; - return mockWebsocketSubscription; - }), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(websocketCallback).toBeDefined(); - websocketCallback!('connected'); - - expect(mockNetworkConnectivityManager.updateState).toHaveBeenCalledWith( - 'connected', - {isInternetReachable: null}, - 'active', - ); - }); - - it('should unsubscribe from previous websocket subscription when servers change', async () => { - const mockServers1: MockServer[] = [{url: 'https://server1.com', lastActiveAt: 1000}]; - const mockServers2: MockServer[] = [{url: 'https://server2.com', lastActiveAt: 2000}]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - - await serversCallback(mockServers1 as any); // eslint-disable-line @typescript-eslint/no-explicit-any - expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com'); - - await serversCallback(mockServers2 as any); // eslint-disable-line @typescript-eslint/no-explicit-any - expect(mockWebsocketSubscription.unsubscribe).toHaveBeenCalled(); - expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com'); - }); - }); - - describe('performance subscription handling', () => { - it('should subscribe to performance state changes', async () => { - const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://test.com'); - }); - - it('should call updatePerformanceState when performance state changes', async () => { - let performanceCallback: ((state: string) => void) | undefined; - mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ - subscribe: jest.fn((callback: (state: string) => void) => { - performanceCallback = callback; - return mockPerformanceSubscription; - }), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(performanceCallback).toBeDefined(); - performanceCallback!('slow'); - - expect(mockNetworkConnectivityManager.updatePerformanceState).toHaveBeenCalledWith('slow'); - }); - }); - - describe('edge cases', () => { - it('should find most recent server when first server is more recent', async () => { - const mockServers: MockServer[] = [ - {url: 'https://server1.com', lastActiveAt: 2000}, - {url: 'https://server2.com', lastActiveAt: 1000}, - ]; - - startNetworkConnectivitySubscriptions(); - - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server1.com'); - expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com'); - }); - - it('should reinitialize when app becomes active after subscriptions were cleared', () => { - const mockAppStateListener = {remove: jest.fn()}; - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - - mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - - const appStateCallback = mockAppState.addEventListener.mock.calls[0][1]; - - mockNetInfo.addEventListener.mockClear(); - mockSubscribeActiveServers.mockClear(); - - appStateCallback('background'); - - expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); - - mockNetInfo.addEventListener.mockClear(); - mockSubscribeActiveServers.mockClear(); - - appStateCallback('active'); - - expect(mockNetInfo.addEventListener).toHaveBeenCalled(); - expect(mockSubscribeActiveServers).toHaveBeenCalled(); - }); - - it('should not reinitialize when transitioning to active if subscriptions exist', async () => { - startNetworkConnectivitySubscriptions(); - - const appStateCallback = mockAppState.addEventListener.mock.calls[0][1]; - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - - await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - mockNetInfo.addEventListener.mockClear(); - mockSubscribeActiveServers.mockClear(); - - appStateCallback('active'); - - expect(mockNetInfo.addEventListener).not.toHaveBeenCalled(); - expect(mockSubscribeActiveServers).not.toHaveBeenCalled(); - }); - - it('should handle netInfo with undefined isInternetReachable', () => { - startNetworkConnectivitySubscriptions(); - - const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0]; - const mockNetInfoState = {} as MockNetInfoState; - netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function)); - }); - }); - - describe('shutdownNetworkConnectivitySubscriptions', () => { - it('should clean up all subscriptions including AppState listener', async () => { - const mockAppStateListener = {remove: jest.fn()}; - const mockNetInfoUnsubscriber = jest.fn(); - const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; - const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()}; - const mockPerformanceSubscriptionForCleanup = {unsubscribe: jest.fn()}; - - mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any - mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber); - mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - mockWebsocketManager.observeWebsocketState.mockReturnValue({ - subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ - subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - startNetworkConnectivitySubscriptions(); - - // Simulate active servers to create websocket subscription - const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; - await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - shutdownNetworkConnectivitySubscriptions(); - - expect(mockAppStateListener.remove).toHaveBeenCalled(); - expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); - expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); - expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); - expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); - }); - }); -}); diff --git a/app/managers/network_connectivity_subscription_manager.ts b/app/managers/network_connectivity_subscription_manager.ts deleted file mode 100644 index f73f82276..000000000 --- a/app/managers/network_connectivity_subscription_manager.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import NetInfo, {type NetInfoSubscription} from '@react-native-community/netinfo'; -import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native'; -import {Subscription} from 'rxjs'; - -import {subscribeActiveServers} from '@database/subscription/servers'; - -import NetworkConnectivityManager from './network_connectivity_manager'; -import NetworkPerformanceManager from './network_performance_manager'; -import WebsocketManager from './websocket_manager'; - -type Server = { - url: string; - lastActiveAt: number; -}; - -class NetworkConnectivitySubscriptionManagerSingleton { - private appState: AppStateStatus = 'active'; - private isInternetReachable: boolean | null = null; - private appSubscription: NativeEventSubscription | undefined; - private netInfoSubscription: NetInfoSubscription | undefined; - private activeServersUnsubscriber: {unsubscribe: () => void} | undefined; - private websocketSubscription: Subscription | undefined; - private performanceSubscription: Subscription | undefined; - - private findMostRecentServer = (servers: Server[]): Server | undefined => { - return servers?.length ? servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)) : undefined; - }; - - private cleanupWebsocketSubscription = (): void => { - this.websocketSubscription?.unsubscribe(); - this.websocketSubscription = undefined; - }; - - private cleanupPerformanceSubscription = (): void => { - this.performanceSubscription?.unsubscribe(); - this.performanceSubscription = undefined; - }; - - private handleActiveServersChange = async (servers: Server[]): Promise => { - this.cleanupWebsocketSubscription(); - this.cleanupPerformanceSubscription(); - - const activeServer = this.findMostRecentServer(servers); - const serverUrl = activeServer?.url; - - if (!serverUrl) { - NetworkConnectivityManager.setServerConnectionStatus(false, null); - NetworkConnectivityManager.shutdown(); - return; - } - - NetworkConnectivityManager.setServerConnectionStatus(true, serverUrl); - - this.websocketSubscription = WebsocketManager.observeWebsocketState(serverUrl).subscribe((websocketState) => { - NetworkConnectivityManager.updateState( - websocketState, - {isInternetReachable: this.isInternetReachable}, - this.appState, - ); - }); - - this.performanceSubscription = NetworkPerformanceManager.observePerformanceState(serverUrl).subscribe((performanceState) => { - NetworkConnectivityManager.updatePerformanceState(performanceState); - }); - }; - - private handleAppStateChange = (newAppState: AppStateStatus): void => { - const previousAppState = this.appState; - this.appState = newAppState; - - if (previousAppState === newAppState) { - return; - } - - if (newAppState === 'active') { - if (!this.netInfoSubscription || !this.activeServersUnsubscriber) { - this.init(); - } - } else if (previousAppState === 'active') { - NetworkConnectivityManager.reset(); - this.stop(); - } - }; - - private handleNetInfoChange = (netInfo: {isInternetReachable: boolean | null}): void => { - this.isInternetReachable = netInfo.isInternetReachable ?? null; - }; - - /** - * Initializes all network connectivity subscriptions including app state, network info, - * and active servers. This starts the global connectivity monitoring. - */ - public init = (): void => { - if (!this.appSubscription) { - this.appSubscription = AppState.addEventListener('change', this.handleAppStateChange); - } - - this.netInfoSubscription?.(); - this.netInfoSubscription = NetInfo.addEventListener(this.handleNetInfoChange); - - this.activeServersUnsubscriber?.unsubscribe?.(); - this.activeServersUnsubscriber = subscribeActiveServers(this.handleActiveServersChange); - }; - - private cleanupAllSubscriptions = (): void => { - this.websocketSubscription?.unsubscribe(); - this.performanceSubscription?.unsubscribe(); - this.activeServersUnsubscriber?.unsubscribe?.(); - this.netInfoSubscription?.(); - - this.websocketSubscription = undefined; - this.performanceSubscription = undefined; - this.activeServersUnsubscriber = undefined; - this.netInfoSubscription = undefined; - this.isInternetReachable = null; - }; - - /** - * Stops network connectivity subscriptions and cleans up resources. - * AppState listener persists to handle app lifecycle transitions. - */ - public stop = (): void => { - this.cleanupAllSubscriptions(); - }; - - /** - * Completely shuts down all subscriptions including the persistent AppState listener. - * This should only be called when the app is completely shutting down. - */ - public shutdown = (): void => { - this.websocketSubscription?.unsubscribe(); - this.performanceSubscription?.unsubscribe(); - this.activeServersUnsubscriber?.unsubscribe?.(); - this.netInfoSubscription?.(); - this.appSubscription?.remove(); - - this.appState = 'active'; - this.isInternetReachable = null; - this.appSubscription = undefined; - this.netInfoSubscription = undefined; - this.activeServersUnsubscriber = undefined; - this.websocketSubscription = undefined; - this.performanceSubscription = undefined; - }; -} - -const NetworkConnectivitySubscriptionManager = new NetworkConnectivitySubscriptionManagerSingleton(); - -export const startNetworkConnectivitySubscriptions = (): void => { - NetworkConnectivitySubscriptionManager.init(); -}; - -export const stopNetworkConnectivitySubscriptions = (): void => { - NetworkConnectivitySubscriptionManager.stop(); -}; - -export const shutdownNetworkConnectivitySubscriptions = (): void => { - NetworkConnectivitySubscriptionManager.shutdown(); -}; - -export default NetworkConnectivitySubscriptionManager; - -export const testExports = { - NetworkConnectivitySubscriptionManagerSingleton, -}; - diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 4eb5988ec..6e8d1286a 100644 --- a/app/managers/network_manager.ts +++ b/app/managers/network_manager.ts @@ -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: true, + collectMetrics: LocalConfig.CollectNetworkMetrics, }, headers, }; diff --git a/app/managers/network_performance_manager.test.ts b/app/managers/network_performance_manager.test.ts deleted file mode 100644 index 3205c4574..000000000 --- a/app/managers/network_performance_manager.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/* eslint-disable max-lines */ - -import {of as of$} from 'rxjs'; - -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(), - })), - }, -})); - -jest.mock('@queries/app/global', () => { - const {of: mockOf} = require('rxjs'); - return { - observeLowConnectivityMonitor: jest.fn(() => mockOf(true)), - }; -}); - -const { - NetworkPerformanceManagerSingleton, - REQUEST_OUTCOME_WINDOW_SIZE, - MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, - MINIMUM_REQUESTS_FOR_SUBSEQUENT_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 current state 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, 'normal'); - 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, 'normal'); - 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, 'normal'); - expect(state).toBe('normal'); - }); - }); - - describe('subsequent detection', () => { - it('should return current state 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 slowState = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow'); - expect(slowState).toBe('slow'); - - const normalState = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal'); - expect(normalState).toBe('normal'); - }); - - 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, 'slow'); - 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, 'normal'); - 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, 'normal'); - expect(state).toBe('slow'); - }); - }); - }); -}); - -describe('NetworkPerformanceManager', () => { - let performanceManager: InstanceType; - const serverUrl = 'https://test-server.com'; - const {observeLowConnectivityMonitor} = require('@queries/app/global'); - - beforeEach(() => { - observeLowConnectivityMonitor.mockReturnValue(of$(true)); - performanceManager = new NetworkPerformanceManagerSingleton(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - 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(1000, 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: string) => { - states.push(state); - }); - - for (let i = 0; i < 10; i++) { - const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 3 ? 1000 : 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: string) => { - states.push(state); - }); - - for (let i = 0; i < 10; i++) { - const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 7 ? 1000 : 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: string) => { - 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 ? 1000 : 500, 1000, 500); // 3 out of 4 = 75% - performanceManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - subscription.unsubscribe(); - expect(states).toEqual(['normal', 'slow']); - }); - - it('should require minimum requests for subsequent detection after initial phase', () => { - const states: string[] = []; - const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => { - 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(1000, 1000, 500); - performanceManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - for (let i = 0; i < MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; i++) { - const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`); - const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500); - performanceManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - subscription.unsubscribe(); - - expect(states[0]).toBe('normal'); - expect(states).toContain('slow'); - }); - }); - - describe('outcome statistics', () => { - it('should provide accurate request outcome statistics', () => { - performanceManager.observePerformanceState(serverUrl); - - for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE; i++) { - const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500); - performanceManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - const stats = performanceManager.getRequestOutcomeStats(serverUrl); - expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE); - expect(stats.slowRequests).toBe(7); - expect(stats.slowPercentage).toBeCloseTo(7 / REQUEST_OUTCOME_WINDOW_SIZE); - 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 ? 1000 : 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(1000, 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: string) => { - 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(1000, 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 ? 1000 : 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(1000, 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 log when performance recovers from slow to normal', () => { - 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(1000, 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).toHaveBeenCalledWith( - `Network performance improved for ${serverUrl}: slow -> normal`, - 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), - }), - }), - ); - }); - - }); - - 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: string) => { - states.push(state); - }); - - for (let i = 0; i < 10; i++) { - const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 8 ? 1000 : 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(); - }); - }); - - describe('Low Connectivity Monitor Integration', () => { - it('should subscribe to observeLowConnectivityMonitor on construction', () => { - expect(observeLowConnectivityMonitor).toHaveBeenCalled(); - }); - - it('should emit performance state changes when monitoring is enabled', () => { - const states: string[] = []; - const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => { - 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(1000, 1000, 500); - performanceManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - subscription.unsubscribe(); - expect(states).toEqual(['normal', 'slow']); - }); - - it('should not emit performance state changes when monitoring is disabled', () => { - observeLowConnectivityMonitor.mockReturnValue(of$(false)); - const disabledManager = new NetworkPerformanceManagerSingleton(); - - const states: string[] = []; - const subscription = disabledManager.observePerformanceState(serverUrl).subscribe((state: string) => { - states.push(state); - }); - - for (let i = 0; i < 10; i++) { - const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500); - disabledManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - subscription.unsubscribe(); - expect(states).toEqual([]); - - disabledManager.destroy(); - }); - - it('should still track requests and log when monitoring is disabled', () => { - const {logDebug} = require('@utils/log'); - logDebug.mockClear(); - - observeLowConnectivityMonitor.mockReturnValue(of$(false)); - const disabledManager = new NetworkPerformanceManagerSingleton(); - - disabledManager.observePerformanceState(serverUrl); - - for (let i = 0; i < 10; i++) { - const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500); - disabledManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - const stats = disabledManager.getRequestOutcomeStats(serverUrl); - expect(stats.totalRequests).toBe(10); - expect(stats.slowRequests).toBe(8); - - expect(logDebug).toHaveBeenCalledWith( - `Network performance degraded for ${serverUrl}: normal -> slow`, - expect.any(Object), - ); - - disabledManager.destroy(); - }); - - it('should clean up monitor subscription on destroy', () => { - const mockUnsubscribe = jest.fn(); - observeLowConnectivityMonitor.mockReturnValue({ - subscribe: jest.fn(() => ({ - unsubscribe: mockUnsubscribe, - })), - }); - - const manager = new NetworkPerformanceManagerSingleton(); - manager.destroy(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - - it('should handle monitor state changing from enabled to disabled', () => { - const {BehaviorSubject} = require('rxjs'); - const monitorSubject = new BehaviorSubject(true); - observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable()); - - const dynamicManager = new NetworkPerformanceManagerSingleton(); - - const states: string[] = []; - const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => { - states.push(state); - }); - - for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) { - const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(1000, 1000, 500); - dynamicManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - expect(states).toEqual(['normal', 'slow']); - - monitorSubject.next(false); - - states.length = 0; - - for (let i = 0; i < 5; i++) { - const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`); - const metrics = createMockMetrics(500, 1000, 500); - dynamicManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - expect(states).toEqual([]); - - subscription.unsubscribe(); - dynamicManager.destroy(); - }); - - it('should handle monitor state changing from disabled to enabled', () => { - const {BehaviorSubject} = require('rxjs'); - const monitorSubject = new BehaviorSubject(false); - observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable()); - - const dynamicManager = new NetworkPerformanceManagerSingleton(); - - const states: string[] = []; - const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => { - states.push(state); - }); - - for (let i = 0; i < 5; i++) { - const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(i < 4 ? 1000 : 500, 1000, 500); - dynamicManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - expect(states).toEqual([]); - - monitorSubject.next(true); - - for (let i = 5; i < 10; i++) { - const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`); - const metrics = createMockMetrics(500, 1000, 500); - dynamicManager.completeRequestTracking(serverUrl, requestId, metrics); - } - - expect(states.length).toBeGreaterThan(0); - - subscription.unsubscribe(); - dynamicManager.destroy(); - }); - }); -}); diff --git a/app/managers/network_performance_manager.ts b/app/managers/network_performance_manager.ts deleted file mode 100644 index e4c4b4819..000000000 --- a/app/managers/network_performance_manager.ts +++ /dev/null @@ -1,366 +0,0 @@ -// 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, type Subscription} from 'rxjs'; -import {distinctUntilChanged, filter} from 'rxjs/operators'; - -import {observeLowConnectivityMonitor} from '@queries/app/global'; -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 = 800; -const EARLY_DETECTION_SLOW_THRESHOLD = 2000; -const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7; -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, currentState: NetworkPerformanceState): NetworkPerformanceState => { - const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; - - if (outcomes.length < minimumRequests) { - return currentState; - } - - const recentOutcomes = outcomes.slice(-minimumRequests); - const slowRequestCount = recentOutcomes.filter((outcome) => outcome.isSlow).length; - const slowPercentage = slowRequestCount / recentOutcomes.length; - - return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal'; -}; - -class NetworkPerformanceManagerSingleton { - private performanceSubjects: Record> = {}; - private activeRequests: Record> = {}; - private requestOutcomes: Record = {}; - private totalRequestCount: Record = {}; - private initialRequestTimestamp: Record = {}; - private isInitialDetection: Record = {}; - private appStateSubscription: NativeEventSubscription | null = null; - private lowConnectivityMonitorEnabled = true; - private monitorSubscription: Subscription | null = null; - - constructor() { - this.setupAppStateMonitoring(); - this.setupMonitorObserver(); - } - - /** - * 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); - }, EARLY_DETECTION_SLOW_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) => { - 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) { - 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. - * Only emits when low connectivity monitoring is enabled. - */ - public observePerformanceState = (serverUrl: string) => { - return this.getPerformanceSubject(serverUrl).asObservable().pipe( - filter(() => this.lowConnectivityMonitorEnabled), - 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, useRecentOnly = false) => { - let outcomes = this.requestOutcomes[serverUrl] || []; - - if (useRecentOnly && outcomes.length > 0) { - const isInitialDetection = !this.isInitialDetection[serverUrl]; - const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; - outcomes = outcomes.slice(-minimumRequests); - } - - 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.totalRequestCount[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; - } - - if (this.monitorSubscription) { - this.monitorSubscription.unsubscribe(); - this.monitorSubscription = 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.totalRequestCount = {}; - 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 >= EARLY_DETECTION_SLOW_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.totalRequestCount[serverUrl]) { - this.totalRequestCount[serverUrl] = 0; - } - - if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) { - this.initialRequestTimestamp[serverUrl] = outcome.timestamp; - } - - this.requestOutcomes[serverUrl].push(outcome); - this.totalRequestCount[serverUrl]++; - - 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, currentPerformanceState); - - if (currentPerformanceState !== newPerformanceState) { - const stats = this.getRequestOutcomeStats(serverUrl, true); - const statusChange = newPerformanceState === 'slow' ? 'degraded' : 'improved'; - - const logData: Record = { - totalRequests: stats.totalRequests, - slowRequests: stats.slowRequests, - slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`, - earlyDetectionCount: stats.earlyDetectionCount, - lastOutcome: { - isSlow: outcome.isSlow, - wasEarlyDetection: outcome.wasEarlyDetection, - }, - }; - - if (newPerformanceState === 'slow') { - const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl]; - logData.currentTimestamp = Date.now(); - logData.detectionDelayMs = detectionDelayMs; - logData.detectionDelaySeconds = `${(detectionDelayMs / 1000).toFixed(1)}s`; - - this.isInitialDetection[serverUrl] = true; - delete this.initialRequestTimestamp[serverUrl]; - } - - logDebug(`Network performance ${statusChange} for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, logData); - } - - 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 setupMonitorObserver = () => { - this.monitorSubscription = observeLowConnectivityMonitor().subscribe((enabled) => { - this.lowConnectivityMonitorEnabled = enabled; - }); - }; - - 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, - EARLY_DETECTION_SLOW_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/app/managers/session_manager.ts b/app/managers/session_manager.ts index d76aad642..49ac6a9ba 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -13,10 +13,7 @@ import {resetMomentLocale} from '@i18n'; import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; import {relaunchApp} from '@init/launch'; import PushNotifications from '@init/push_notifications'; -import NetworkConnectivityManager from '@managers/network_connectivity_manager'; -import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager'; import NetworkManager from '@managers/network_manager'; -import NetworkPerformanceManager from '@managers/network_performance_manager'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getAllServers, getServerDisplayName} from '@queries/app/servers'; @@ -120,10 +117,7 @@ export class SessionManagerSingleton { PushNotifications.removeServerNotifications(serverUrl); SecurityManager.removeServer(serverUrl); - NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl); NetworkManager.invalidateClient(serverUrl); - NetworkConnectivitySubscriptionManager.stop(); - NetworkPerformanceManager.removeServer(serverUrl); WebsocketManager.invalidateClient(serverUrl); if (removeServer) { diff --git a/app/queries/app/global.test.ts b/app/queries/app/global.test.ts deleted file mode 100644 index c32268624..000000000 --- a/app/queries/app/global.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {of as of$} from 'rxjs'; - -import DatabaseManager from '@database/manager'; - -import {observeLowConnectivityMonitor} from './global'; - -import type {AppDatabase} from '@typings/database/database'; - -jest.mock('@database/manager', () => ({ - getAppDatabaseAndOperator: jest.fn(), -})); - -describe('observeLowConnectivityMonitor', () => { - const mockDatabase = { - get: jest.fn(), - }; - - const mockQuery = { - query: jest.fn(), - observe: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockReturnValue({ - database: mockDatabase, - operator: {}, - } as unknown as AppDatabase); - }); - - it('should return observable that defaults to true when no database record exists', (done) => { - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(true); - done(); - }); - }); - - it('should return observable with database value when record exists', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$({value: false})), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(false); - done(); - }); - }); - - it('should handle boolean value directly', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$(true)), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(true); - done(); - }); - }); - - it('should handle false boolean value directly', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$(false)), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(false); - done(); - }); - }); - - it('should default to true when record value is undefined', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$({value: undefined})), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(true); - done(); - }); - }); - - it('should default to true when query returns null', (done) => { - jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockImplementation(() => { - throw new Error('Database not found'); - }); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(true); - done(); - }); - }); - - it('should handle enabled state (true)', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$({value: true})), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(true); - done(); - }); - }); - - it('should handle disabled state (false)', (done) => { - const mockRecord = { - observe: jest.fn().mockReturnValue(of$({value: false})), - }; - - mockDatabase.get.mockReturnValue({ - query: jest.fn().mockReturnValue(mockQuery), - }); - mockQuery.observe.mockReturnValue(of$([mockRecord])); - - const observable = observeLowConnectivityMonitor(); - - observable.subscribe((value) => { - expect(value).toBe(false); - done(); - }); - }); -}); - diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index 62fe04de9..e1ff99bb8 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -100,19 +100,3 @@ export const observeTutorialWatched = (tutorial: string) => { switchMap((v) => of$(Boolean(v))), ); }; - -export const observeLowConnectivityMonitor = () => { - const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR); - if (!query) { - return of$(true); - } - return query.observe().pipe( - switchMap((result) => (result.length ? result[0].observe() : of$(true))), - switchMap((v) => { - if (typeof v === 'boolean') { - return of$(v); - } - return of$(v?.value ?? true); - }), - ); -}; diff --git a/app/screens/floating_banner/index.tsx b/app/screens/floating_banner/index.tsx deleted file mode 100644 index 303251c44..000000000 --- a/app/screens/floating_banner/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import FloatingBanner from '@components/floating_banner/floating_banner'; - -import type {FloatingBannerConfig} from '@components/floating_banner/types'; - -type FloatingBannerScreenProps = { - banners: FloatingBannerConfig[]; - onDismiss: (id: string) => void; -}; - -const FloatingBannerScreen: React.FC = (props) => { - return ; -}; - -export default FloatingBannerScreen; diff --git a/app/screens/home/channel_list/channel_list.tsx b/app/screens/home/channel_list/channel_list.tsx index 5aa08886d..4fcd5467e 100644 --- a/app/screens/home/channel_list/channel_list.tsx +++ b/app/screens/home/channel_list/channel_list.tsx @@ -12,6 +12,7 @@ import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area import {refetchCurrentUser} from '@actions/remote/user'; import FloatingCallContainer from '@calls/components/floating_call_container'; import AnnouncementBanner from '@components/announcement_banner'; +import ConnectionBanner from '@components/connection_banner'; import TeamSidebar from '@components/team_sidebar'; import {Navigation as NavigationConstants, Screens} from '@constants'; import {useServerUrl} from '@context/server'; @@ -156,7 +157,7 @@ const ChannelListScreen = (props: ChannelProps) => { if (!props.hasCurrentUser || !props.currentUserId) { refetchCurrentUser(serverUrl, props.currentUserId); } - }, [props.currentUserId, props.hasCurrentUser, serverUrl]); + }, [props.currentUserId, props.hasCurrentUser]); // Init the rate app. Only run the effect on the first render if ToS is not open useEffect(() => { @@ -167,12 +168,12 @@ const ChannelListScreen = (props: ChannelProps) => { if (!NavigationStore.isToSOpen()) { tryRunAppReview(props.launchType, props.coldStart); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial render only. + }, []); useEffect(() => { PerformanceMetricsManager.finishLoad('HOME', serverUrl); PerformanceMetricsManager.measureTimeToInteraction(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial render only. + }, []); return ( <> @@ -182,6 +183,7 @@ const ChannelListScreen = (props: ChannelProps) => { edges={edges} testID='channel_list.screen' > + {props.isLicensed && } diff --git a/app/screens/index.tsx b/app/screens/index.tsx index a55c8ab49..43268e958 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -139,11 +139,6 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.GENERIC_OVERLAY: screen = withServerDatabase(require('@screens/overlay').default); break; - case Screens.FLOATING_BANNER: { - const floatingBannerScreen = withServerDatabase(require('@screens/floating_banner').default); - Navigation.registerComponent(Screens.FLOATING_BANNER, () => withSafeAreaInsets(floatingBannerScreen)); - return; - } case Screens.GLOBAL_DRAFTS: screen = withServerDatabase(require('@screens/global_drafts').default); break; diff --git a/app/screens/navigation.test.ts b/app/screens/navigation.test.ts index 7e0c4a361..1cf57486c 100644 --- a/app/screens/navigation.test.ts +++ b/app/screens/navigation.test.ts @@ -6,16 +6,11 @@ import {Navigation} from 'react-native-navigation'; import {Events, Preferences, Screens} from '@constants'; import NavigationStore from '@store/navigation_store'; -jest.unmock('@screens/navigation'); - -import * as navigationModule from './navigation'; -import {dismissAllOverlaysWithExceptions} from './navigation'; +import {openAsBottomSheet} from './navigation'; import type {FirstArgument} from '@typings/utils/utils'; import type {IntlShape} from 'react-intl'; -const {registerNavigationListeners} = navigationModule; - function expectShowModalCalledWith(screen: string, title: string, props?: Record) { expect(Navigation.showModal).toHaveBeenCalledWith({ stack: { @@ -38,7 +33,7 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record, isTablet: boolean) { +function expectOpenAsBottomSheetCalledWith(props: FirstArgument, isTablet: boolean) { if (isTablet) { expectShowModalCalledWith(props.screen, props.title, {closeButtonId: props.closeButtonId, ...props.props}); } else { @@ -112,373 +107,3 @@ describe('openUserProfileModal', () => { }, false); }); }); - -describe('overlay command listeners', () => { - let mockCommandListener: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - - mockCommandListener = jest.fn(); - const mockEvents = { - registerScreenPoppedListener: jest.fn(() => ({remove: jest.fn()})), - registerCommandListener: jest.fn((listener) => { - mockCommandListener = listener; - return {remove: jest.fn()}; - }), - registerComponentWillAppearListener: jest.fn(() => ({remove: jest.fn()})), - }; - (Navigation.events as jest.Mock).mockReturnValue(mockEvents); - - registerNavigationListeners(); - }); - - it('should call NavigationStore.addOverlayToStack when showOverlay command is received', () => { - const overlayId = 'test-overlay-id'; - const params = {layout: {id: overlayId}}; - - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - - mockCommandListener('showOverlay', params); - - expect(NavigationStore.getOverlaysInStack()).toEqual([overlayId]); - }); - - it('should call NavigationStore.removeOverlayFromStack when dismissOverlay command is received', () => { - const overlayId = 'test-overlay-id'; - NavigationStore.addOverlayToStack(overlayId); - NavigationStore.addOverlayToStack('another-overlay'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['another-overlay', overlayId]); - - mockCommandListener('dismissOverlay', {componentId: overlayId}); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['another-overlay']); - }); - - it('should call NavigationStore.removeAllOverlaysFromStack when dismissAllOverlays command is received', () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('overlay2'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['overlay2', 'floating-banner-overlay', 'overlay1']); - - mockCommandListener('dismissAllOverlays', {}); - - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - }); - - it('should not affect NavigationStore when unrecognized commands are received', () => { - NavigationStore.addOverlayToStack('test-overlay'); - const initialOverlays = NavigationStore.getOverlaysInStack(); - - mockCommandListener('someOtherCommand', {}); - - expect(NavigationStore.getOverlaysInStack()).toEqual(initialOverlays); - }); -}); - -describe('showOverlay', () => { - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - }); - - it('should call Navigation.showOverlay with correct parameters', () => { - const screenName = Screens.USER_PROFILE; - const passProps = {userId: 'user123'}; - const options = {overlay: {interceptTouchOutside: true}}; - const id = 'custom-overlay-id'; - - navigationModule.showOverlay(screenName, passProps, options, id); - - expect(Navigation.showOverlay).toHaveBeenCalledWith({ - component: { - id: 'custom-overlay-id', - name: screenName, - passProps, - options: expect.objectContaining({ - layout: { - backgroundColor: 'transparent', - componentBackgroundColor: 'transparent', - }, - overlay: { - interceptTouchOutside: true, - }, - }), - }, - }); - }); - - it('should use default options when none provided', () => { - const screenName = Screens.USER_PROFILE; - - navigationModule.showOverlay(screenName); - - expect(Navigation.showOverlay).toHaveBeenCalledWith({ - component: { - id: undefined, - name: screenName, - passProps: {}, - options: { - layout: { - backgroundColor: 'transparent', - componentBackgroundColor: 'transparent', - }, - overlay: { - interceptTouchOutside: false, - }, - }, - }, - }); - }); - - it('should merge custom options with default options correctly', () => { - const screenName = Screens.USER_PROFILE; - const customOptions = { - layout: {backgroundColor: 'red'}, - overlay: {interceptTouchOutside: true}, - customProperty: 'test', - }; - - navigationModule.showOverlay(screenName, {}, customOptions); - - expect(Navigation.showOverlay).toHaveBeenCalledWith({ - component: { - id: undefined, - name: screenName, - passProps: {}, - options: expect.objectContaining({ - layout: { - backgroundColor: 'red', - componentBackgroundColor: 'transparent', - }, - overlay: { - interceptTouchOutside: true, - }, - customProperty: 'test', - }), - }, - }); - }); -}); - -describe('dismissOverlay', () => { - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - }); - - it('should call Navigation.dismissOverlay with correct componentId', async () => { - const componentId = 'overlay-123'; - - await navigationModule.dismissOverlay(componentId); - - expect(Navigation.dismissOverlay).toHaveBeenCalledWith(componentId); - }); - - it('should handle Navigation.dismissOverlay rejection gracefully', async () => { - const componentId = 'non-existent-overlay'; - - (Navigation.dismissOverlay as jest.Mock).mockRejectedValueOnce(new Error('Overlay not found')); - - await expect(navigationModule.dismissOverlay(componentId)).resolves.not.toThrow(); - - expect(Navigation.dismissOverlay).toHaveBeenCalledWith(componentId); - }); -}); - -describe('dismissAllOverlays', () => { - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - }); - - it('should call Navigation.dismissAllOverlays', async () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('overlay2'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['overlay2', 'floating-banner-overlay', 'overlay1']); - - await navigationModule.dismissAllOverlays(); - - expect(Navigation.dismissAllOverlays).toHaveBeenCalledTimes(1); - expect(Navigation.dismissOverlay).not.toHaveBeenCalled(); - }); - - it('should handle empty overlay stack gracefully', async () => { - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - - await navigationModule.dismissAllOverlays(); - - expect(Navigation.dismissAllOverlays).toHaveBeenCalledTimes(1); - }); - - it('should handle only exception overlays in stack', async () => { - NavigationStore.addOverlayToStack('floating-banner-overlay'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['floating-banner-overlay']); - - await navigationModule.dismissAllOverlays(); - - expect(Navigation.dismissAllOverlays).toHaveBeenCalledTimes(1); - }); - - it('should handle Navigation.dismissAllOverlays rejection gracefully', async () => { - (Navigation.dismissAllOverlays as jest.Mock).mockRejectedValueOnce(new Error('Dismiss failed')); - - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - - await expect(navigationModule.dismissAllOverlays()).resolves.not.toThrow(); - - expect(Navigation.dismissAllOverlays).toHaveBeenCalledTimes(1); - }); - - it('should call Navigation.dismissAllOverlays once regardless of overlay count', async () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - NavigationStore.addOverlayToStack('overlay3'); - - await navigationModule.dismissAllOverlays(); - - expect(Navigation.dismissAllOverlays).toHaveBeenCalledTimes(1); - expect(Navigation.dismissOverlay).not.toHaveBeenCalled(); - }); -}); - -describe('dismissAllModalsAndPopToRoot', () => { - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - }); - - it('should call dismissAllModals, dismissAllOverlaysWithExceptions, and popToRoot in sequence', async () => { - NavigationStore.addModalToStack('AppForm'); - NavigationStore.addScreenToStack('Home'); - - await navigationModule.dismissAllModalsAndPopToRoot(); - - expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); - expect(Navigation.dismissModal).toHaveBeenCalledWith('AppForm', {animations: {dismissModal: {enabled: false}}}); - expect(Navigation.popToRoot).toHaveBeenCalledTimes(1); - expect(Navigation.popToRoot).toHaveBeenCalledWith('Home'); - }); - - it('should dismiss non-exception overlays via dismissAllOverlaysWithExceptions', async () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('overlay2'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['overlay2', 'floating-banner-overlay', 'overlay1']); - - await navigationModule.dismissAllModalsAndPopToRoot(); - - expect(Navigation.dismissOverlay).toHaveBeenCalledTimes(2); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay2'); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay1'); - expect(Navigation.dismissOverlay).not.toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should handle empty overlay stack gracefully', async () => { - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - NavigationStore.addScreenToStack('Home'); - - await navigationModule.dismissAllModalsAndPopToRoot(); - - expect(Navigation.dismissModal).not.toHaveBeenCalled(); - expect(Navigation.dismissOverlay).not.toHaveBeenCalled(); - expect(Navigation.popToRoot).toHaveBeenCalledTimes(1); - expect(Navigation.popToRoot).toHaveBeenCalledWith('Home'); - }); - - it('should continue with popToRoot even if overlay dismissal fails', async () => { - (Navigation.dismissOverlay as jest.Mock).mockRejectedValueOnce(new Error('Dismiss failed')); - - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addScreenToStack('Home'); - - await expect(navigationModule.dismissAllModalsAndPopToRoot()).resolves.not.toThrow(); - - expect(Navigation.dismissModal).not.toHaveBeenCalled(); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay1'); - expect(Navigation.popToRoot).toHaveBeenCalledTimes(1); - expect(Navigation.popToRoot).toHaveBeenCalledWith('Home'); - }); -}); - -describe('dismissAllOverlaysWithExceptions', () => { - beforeEach(() => { - jest.clearAllMocks(); - NavigationStore.reset(); - }); - - it('should dismiss non-exception overlays individually', async () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('overlay2'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['overlay2', 'floating-banner-overlay', 'overlay1']); - - await dismissAllOverlaysWithExceptions(); - - expect(Navigation.dismissOverlay).toHaveBeenCalledTimes(2); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay2'); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay1'); - expect(Navigation.dismissOverlay).not.toHaveBeenCalledWith('floating-banner-overlay'); - }); - - it('should handle empty overlay stack gracefully', async () => { - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - - await dismissAllOverlaysWithExceptions(); - - expect(Navigation.dismissOverlay).not.toHaveBeenCalled(); - }); - - it('should handle only exception overlays in stack', async () => { - NavigationStore.addOverlayToStack('floating-banner-overlay'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['floating-banner-overlay']); - - await dismissAllOverlaysWithExceptions(); - - expect(Navigation.dismissOverlay).not.toHaveBeenCalled(); - }); - - it('should use Promise.all for concurrent dismissals', async () => { - const dismissalOrder: string[] = []; - (Navigation.dismissOverlay as jest.Mock).mockImplementation((overlayId: string) => { - dismissalOrder.push(overlayId); - return Promise.resolve(); - }); - - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - NavigationStore.addOverlayToStack('overlay3'); - - await dismissAllOverlaysWithExceptions(); - - expect(Navigation.dismissOverlay).toHaveBeenCalledTimes(3); - expect(dismissalOrder).toContain('overlay1'); - expect(dismissalOrder).toContain('overlay2'); - expect(dismissalOrder).toContain('overlay3'); - }); - - it('should continue dismissing even if one overlay dismissal fails', async () => { - (Navigation.dismissOverlay as jest.Mock). - mockImplementationOnce(() => Promise.reject(new Error('Dismiss failed'))). - mockImplementationOnce(() => Promise.resolve()); - - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - - await expect(dismissAllOverlaysWithExceptions()).resolves.not.toThrow(); - - expect(Navigation.dismissOverlay).toHaveBeenCalledTimes(2); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay2'); - expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay1'); - }); -}); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 23c7b592f..604ed9fad 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -100,16 +100,6 @@ function onCommandListener(name: string, params: any) { case 'dismissModal': NavigationStore.removeModalFromStack(params.componentId); break; - case 'showOverlay': - NavigationStore.addOverlayToStack(params?.layout?.id); - break; - case 'dismissOverlay': - NavigationStore.removeOverlayFromStack(params?.componentId); - break; - case 'dismissAllOverlays': { - NavigationStore.removeAllOverlaysFromStack(); - break; - } } const screen = NavigationStore.getVisibleScreen(); @@ -619,7 +609,7 @@ export async function popToRoot() { export async function dismissAllModalsAndPopToRoot() { await dismissAllModals(); - await dismissAllOverlaysWithExceptions(); + await dismissAllOverlays(); await popToRoot(); } @@ -633,7 +623,7 @@ export async function dismissAllModalsAndPopToRoot() { */ export async function dismissAllModalsAndPopToScreen(screenId: AvailableScreens, title: string, passProps = {}, options = {}) { await dismissAllModals(); - await dismissAllOverlaysWithExceptions(); + await dismissAllOverlays(); if (NavigationStore.getScreensInStack().includes(screenId)) { let mergeOptions = options; if (title) { @@ -859,28 +849,6 @@ export async function dismissOverlay(componentId: string) { } } -/** - * Instead of using native dismissAllOverlays, we're looping through the overlays - * and dismissing them individually. Native dismissAllOverlays is causing the app to - * dismiss 700+ overlays. Even though those overlays doesn't exist in the stack. Since we're - * tracking the overlays in the store, we can dismiss them individually. - * @returns - */ -export async function dismissAllOverlaysWithExceptions() { - try { - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - if (!overlaysToRemove.length) { - return; - } - - await Promise.all(overlaysToRemove.map((overlayId) => - Navigation.dismissOverlay(overlayId).catch(() => undefined), - )); - } catch { - // do nothing - } -} - export async function dismissAllOverlays() { try { await Navigation.dismissAllOverlays(); @@ -1002,4 +970,3 @@ export async function openUserProfileModal( Keyboard.dismiss(); openAsBottomSheet({screen, title, theme, closeButtonId, props: {...props}}); } - diff --git a/app/screens/settings/advanced/advanced.test.tsx b/app/screens/settings/advanced/advanced.test.tsx index 401bc869d..ef0b35278 100644 --- a/app/screens/settings/advanced/advanced.test.tsx +++ b/app/screens/settings/advanced/advanced.test.tsx @@ -5,7 +5,6 @@ import {fireEvent, screen, waitFor} from '@testing-library/react-native'; import React from 'react'; import {Alert} from 'react-native'; -import {storeLowConnectivityMonitor} from '@actions/app/global'; import {Screens} from '@constants'; import {goToScreen} from '@screens/navigation'; import {renderWithIntlAndTheme} from '@test/intl-test-helper'; @@ -28,7 +27,6 @@ jest.mock('@hooks/utils', () => ({ usePreventDoubleTap: jest.fn((callback) => callback), })); -const mockStoreLowConnectivityMonitor = storeLowConnectivityMonitor as jest.Mock; const mockGetAllFilesInCachesDirectory = getAllFilesInCachesDirectory as jest.Mock; const mockDeleteFileCache = deleteFileCache as jest.Mock; const mockGoToScreen = goToScreen as jest.Mock; @@ -85,14 +83,6 @@ describe('AdvancedSettings', () => { }); }); - it('should render low connectivity monitor toggle', async () => { - renderWithIntlAndTheme(); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option')).toBeTruthy(); - }); - }); - it('should render component library option in dev mode', async () => { renderWithIntlAndTheme( { }); }); - describe('low connectivity monitor toggle', () => { - it('should render toggle in off state when disabled', async () => { - renderWithIntlAndTheme( - ); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy(); - }); - }); - - it('should render toggle in on state when enabled', async () => { - renderWithIntlAndTheme( - ); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy(); - }); - }); - - it('should call storeLowConnectivityMonitor when toggled on', async () => { - mockStoreLowConnectivityMonitor.mockResolvedValue({}); - - renderWithIntlAndTheme( - ); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy(); - }); - - const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button'); - - fireEvent(toggle, 'valueChange', true); - - await waitFor(() => { - expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(true); - }); - }); - - it('should call storeLowConnectivityMonitor when toggled off', async () => { - mockStoreLowConnectivityMonitor.mockResolvedValue({}); - - renderWithIntlAndTheme( - ); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy(); - }); - - const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button'); - - fireEvent(toggle, 'valueChange', false); - - await waitFor(() => { - expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(false); - }); - }); - - it('should update local state when toggle is changed', async () => { - mockStoreLowConnectivityMonitor.mockResolvedValue({}); - - renderWithIntlAndTheme( - , - ); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy(); - }); - - const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button'); - - fireEvent(toggle, 'valueChange', true); - - await waitFor(() => { - expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy(); - }); - }); - }); - describe('data fetching', () => { it('should fetch cached files on mount', async () => { renderWithIntlAndTheme(); @@ -444,21 +341,5 @@ describe('AdvancedSettings', () => { expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy(); }); }); - - it('should render experimental features section header', async () => { - renderWithIntlAndTheme(); - - await waitFor(() => { - expect(screen.getByText('Experimental Features')).toBeTruthy(); - }); - }); - - it('should display low connectivity monitor description', async () => { - renderWithIntlAndTheme(); - - await waitFor(() => { - expect(screen.getByText('Display banners when network connectivity or performance issues are detected')).toBeTruthy(); - }); - }); }); }); diff --git a/app/screens/settings/advanced/advanced.tsx b/app/screens/settings/advanced/advanced.tsx index 68a5aeb28..79a40324f 100644 --- a/app/screens/settings/advanced/advanced.tsx +++ b/app/screens/settings/advanced/advanced.tsx @@ -2,11 +2,9 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useState} from 'react'; -import {defineMessage, useIntl} from 'react-intl'; +import {useIntl} from 'react-intl'; import {Alert, TouchableOpacity} from 'react-native'; -import {storeLowConnectivityMonitor} from '@actions/app/global'; -import SettingBlock from '@components/settings/block'; import SettingContainer from '@components/settings/container'; import SettingOption from '@components/settings/option'; import SettingSeparator from '@components/settings/separator'; @@ -25,18 +23,15 @@ const EMPTY_FILES: FileInfo[] = []; type AdvancedSettingsProps = { componentId: AvailableScreens; isDevMode: boolean; - lowConnectivityMonitorEnabled: boolean; }; const AdvancedSettings = ({ componentId, isDevMode, - lowConnectivityMonitorEnabled, }: AdvancedSettingsProps) => { const intl = useIntl(); const serverUrl = useServerUrl(); const [dataSize, setDataSize] = useState(0); const [files, setFiles] = useState(EMPTY_FILES); - const [isLowConnectivityMonitorEnabled, setIsLowConnectivityMonitorEnabled] = useState(lowConnectivityMonitorEnabled); const getAllCachedFiles = useCallback(async () => { const {totalSize = 0, files: cachedFiles} = await getAllFilesInCachesDirectory(serverUrl); @@ -81,14 +76,9 @@ const AdvancedSettings = ({ goToScreen(screen, title); }, [intl]); - const onToggleLowConnectivityMonitor = useCallback(async (value: boolean) => { - setIsLowConnectivityMonitorEnabled(value); - await storeLowConnectivityMonitor(value); - }, []); - useEffect(() => { getAllCachedFiles(); - }, [getAllCachedFiles]); + }, []); const close = useCallback(() => { popTopScreen(componentId); @@ -124,30 +114,9 @@ const AdvancedSettings = ({ testID='advanced_settings.component_library.option' type='none' /> - {/* */} + )} - - - - - - ); }; diff --git a/app/screens/settings/advanced/index.ts b/app/screens/settings/advanced/index.ts index 128fa8fa8..2769077cf 100644 --- a/app/screens/settings/advanced/index.ts +++ b/app/screens/settings/advanced/index.ts @@ -3,7 +3,6 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {observeLowConnectivityMonitor} from '@queries/app/global'; import {observeConfigBooleanValue} from '@queries/servers/system'; import AdvancedSettings from './advanced'; @@ -13,7 +12,6 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { return { isDevMode: observeConfigBooleanValue(database, 'EnableDeveloper', false), - lowConnectivityMonitorEnabled: observeLowConnectivityMonitor(), }; }); diff --git a/app/store/navigation_store.test.ts b/app/store/navigation_store.test.ts index b69f71f33..4a043ce9a 100644 --- a/app/store/navigation_store.test.ts +++ b/app/store/navigation_store.test.ts @@ -113,77 +113,6 @@ describe('NavigationStore', () => { }); }); - describe('overlay management', () => { - it('should add and remove overlays correctly', () => { - NavigationStore.addOverlayToStack('test-overlay'); - NavigationStore.addOverlayToStack('another-overlay'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['another-overlay', 'test-overlay']); - }); - - it('should handle duplicate overlay additions', () => { - NavigationStore.addOverlayToStack('test-overlay'); - NavigationStore.addOverlayToStack('test-overlay'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['test-overlay']); - }); - - it('should remove specific overlays from stack', () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - NavigationStore.addOverlayToStack('overlay3'); - - NavigationStore.removeOverlayFromStack('overlay2'); - - expect(NavigationStore.getOverlaysInStack()).toEqual(['overlay3', 'overlay1']); - }); - - it('should return overlays other than exceptions', () => { - NavigationStore.addOverlayToStack('regular-overlay'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('another-overlay'); - - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - - expect(overlaysToRemove).toEqual(['another-overlay', 'regular-overlay']); - expect(overlaysToRemove).not.toContain('floating-banner-overlay'); - }); - - it('should return empty array when only exception overlays exist', () => { - NavigationStore.addOverlayToStack('floating-banner-overlay'); - - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - - expect(overlaysToRemove).toEqual([]); - }); - - it('should return all overlays when no exceptions exist in stack', () => { - NavigationStore.addOverlayToStack('overlay1'); - NavigationStore.addOverlayToStack('overlay2'); - - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - - expect(overlaysToRemove).toEqual(['overlay2', 'overlay1']); - }); - - it('should handle empty overlay stack gracefully when getting overlays to remove', () => { - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - expect(overlaysToRemove).toEqual([]); - expect(NavigationStore.getOverlaysInStack()).toEqual([]); - }); - - it('should maintain overlay order when filtering', () => { - NavigationStore.addOverlayToStack('first-overlay'); - NavigationStore.addOverlayToStack('floating-banner-overlay'); - NavigationStore.addOverlayToStack('second-overlay'); - NavigationStore.addOverlayToStack('third-overlay'); - - const overlaysToRemove = NavigationStore.getAllOverlaysOtherThanExceptions(); - - expect(overlaysToRemove).toEqual(['third-overlay', 'second-overlay', 'first-overlay']); - }); - }); - describe('screen loading and visibility', () => { it('should handle waitUntilScreenHasLoaded', async () => { const promise = NavigationStore.waitUntilScreenHasLoaded(Screens.ABOUT); diff --git a/app/store/navigation_store.ts b/app/store/navigation_store.ts index cb7733378..60b15f7fe 100644 --- a/app/store/navigation_store.ts +++ b/app/store/navigation_store.ts @@ -5,12 +5,9 @@ import {BehaviorSubject} from 'rxjs'; import type {AvailableScreens} from '@typings/screens/navigation'; -const OVERLAY_EXCEPTIONS = new Set(['floating-banner-overlay']); - class NavigationStoreSingleton { private screensInStack: AvailableScreens[] = []; private modalsInStack: AvailableScreens[] = []; - private overlaysInStack: string[] = []; private visibleTab = 'Home'; private tosOpen = false; @@ -23,7 +20,6 @@ class NavigationStoreSingleton { reset = () => { this.screensInStack = []; this.modalsInStack = []; - this.overlaysInStack = []; this.visibleTab = 'Home'; this.tosOpen = false; this.subject.next(undefined); @@ -89,28 +85,6 @@ class NavigationStoreSingleton { } }; - addOverlayToStack = (overlayId: string) => { - this.removeOverlayFromStack(overlayId); - this.overlaysInStack.unshift(overlayId); - }; - - removeOverlayFromStack = (overlayId: string) => { - const index = this.overlaysInStack.indexOf(overlayId); - if (index > -1) { - this.overlaysInStack.splice(index, 1); - } - }; - - getOverlaysInStack = () => this.overlaysInStack; - - removeAllOverlaysFromStack = () => { - this.overlaysInStack = []; - }; - - getAllOverlaysOtherThanExceptions = () => { - return this.overlaysInStack.filter((overlayId) => !OVERLAY_EXCEPTIONS.has(overlayId)); - }; - setToSOpen = (open: boolean) => { this.tosOpen = open; }; diff --git a/assets/base/config.json b/assets/base/config.json index 074f6d507..2ae360213 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -29,8 +29,5 @@ "ExperimentalNormalizeMarkdownLinks": false, "CustomRequestHeaders": {}, - "CollectNetworkMetrics": false, - "MonitorNetworkPerformance": true + "CollectNetworkMetrics": false } - - diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 57da5d9d2..8a14ba84b 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -272,10 +272,8 @@ "combined_system_message.you": "You", "connection_banner.connected": "Connection restored", "connection_banner.connecting": "Connecting...", - "connection_banner.limited_network_connection": "Limited network connection", - "connection_banner.not_connected": "Unable to connect to network", + "connection_banner.not_connected": "No internet connection", "connection_banner.not_reachable": "The server is not reachable", - "connection_banner.status_unknown": "Connection status unknown", "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...", @@ -634,8 +632,8 @@ "mobile.components.select_server_view.msg_description": "A server is your team's communication hub accessed using a unique URL", "mobile.components.select_server_view.msg_welcome": "Welcome", "mobile.components.select_server_view.proceed": "Proceed", - "mobile.components.select_server_view.sharedSecret": "Pre-authentication secret", - "mobile.components.select_server_view.sharedSecretHelp": "The pre-authentication secret shared by the administrator", + "mobile.components.select_server_view.sharedSecret": "Authentication secret", + "mobile.components.select_server_view.sharedSecretHelp": "The authentication secret shared by the administrator", "mobile.create_channel": "Create", "mobile.create_channel.title": "New channel", "mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members", @@ -1310,9 +1308,6 @@ "settings.advanced.delete": "Delete", "settings.advanced.delete_data": "Delete local files", "settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n", - "settings.advanced.experimental_features": "Experimental Features", - "settings.advanced.low_connectivity_monitor": "Low Connectivity Monitor", - "settings.advanced.low_connectivity_monitor.description": "Display banners when network connectivity or performance issues are detected", "settings.display": "Display", "settings.notice_mobile_link": "mobile apps", "settings.notice_platform_link": "server", diff --git a/docs/network-connectivity-observation.md b/docs/network-connectivity-observation.md deleted file mode 100644 index 17c9e220b..000000000 --- a/docs/network-connectivity-observation.md +++ /dev/null @@ -1,803 +0,0 @@ -# Network Connectivity Observation System - -## Table of Contents -- [Overview](#overview) -- [Architecture](#architecture) -- [System Components](#system-components) -- [Data Flow](#data-flow) -- [Technical Specifications](#technical-specifications) -- [Banner Display Logic](#banner-display-logic) -- [Performance Detection Algorithm](#performance-detection-algorithm) -- [Configuration](#configuration) - -## Overview - -The Network Connectivity Observation System is a comprehensive monitoring solution that tracks network performance and connectivity status in real-time, providing contextual feedback to users through an intelligent banner system. - -### Key Features -- **Real-time Performance Monitoring**: Tracks request latency to detect network degradation -- **Early Detection System**: Identifies slow requests before completion (2s threshold) -- **Smart Banner Management**: Context-aware banner display with anti-spam mechanisms -- **Multi-Server Support**: Independent tracking per server URL -- **Lifecycle Management**: Handles app state transitions (background/foreground) - ---- - -## Architecture - -### System Diagram - -```mermaid -graph TB - subgraph NCSM["NetworkConnectivitySubscriptionManager
Manages 5 Subscriptions"] - direction LR - SUB1[1. AppState] - SUB2[2. NetInfo] - SUB3[3. Active Servers] - SUB4[4. WebSocket State] - SUB5[5. Performance State] - end - - NPM["NetworkPerformanceManager
β€’ Request Tracking
β€’ Performance Detection
β€’ Observable Streams
β€’ Sliding Window 20 requests"] - - NCM["NetworkConnectivityManager
β€’ updateState()
β€’ updatePerformanceState()
β€’ Banner Priority Logic
β€’ Suppression Control"] - - BM[BannerManager
β€’ Show/Hide Banner
β€’ Auto-Hide
β€’ Cleanup] - - CB[ConnectionBanner
FloatingBanner] - - NCSM -->|updates| NCM - NCM -->|controls| BM - BM -->|renders| CB - - style NCSM fill:#e1f5ff - style NCM fill:#fff4e1 - style NPM fill:#f0e1ff - style BM fill:#d4edda -``` - ---- - -## System Components - -### 1. NetworkPerformanceManager - -**Purpose**: Monitors request performance and detects network degradation - -**Key Responsibilities**: -- Track active requests with unique IDs -- Detect slow requests (β‰₯2000ms) early via timers -- Maintain sliding window of request outcomes (20 requests) -- Calculate performance state based on slow request percentage -- Emit performance state changes via RxJS observables - -**Public API**: -```typescript -class NetworkPerformanceManager { - // Start tracking a request, returns unique ID - startRequestTracking(serverUrl: string, url: string): string - - // Complete tracking with metrics - completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics): void - - // Cancel tracking on failure - cancelRequestTracking(serverUrl: string, requestId: string): void - - // Observe performance state changes - observePerformanceState(serverUrl: string): Observable - - // Get current state - getCurrentPerformanceState(serverUrl: string): NetworkPerformanceState - - // Cleanup - removeServer(serverUrl: string): void -} -``` - -### 2. NetworkConnectivityManager - -**Purpose**: Orchestrates banner display based on connectivity and performance states - -**Key Responsibilities**: -- Maintain current WebSocket, network, and performance state -- Implement banner display priority logic -- Handle suppression mechanisms -- Manage first connection vs reconnection scenarios - -**Public API**: -```typescript -class NetworkConnectivityManager { - // Initialize with server URL - init(serverUrl: string | null): void - - // Update connection status - setServerConnectionStatus(connected: boolean, serverUrl: string | null): void - - // Update WebSocket/network state - updateState( - websocketState: WebsocketConnectedState, - netInfo: {isInternetReachable: boolean | null}, - appState: string - ): void - - // Update performance state - updatePerformanceState(performanceState: NetworkPerformanceState): void - - // Cleanup - cleanup(): void - shutdown(): void -} -``` - -### 3. NetworkConnectivitySubscriptionManager - -**Purpose**: Coordinates all network-related subscriptions and server lifecycle - -**Key Responsibilities**: -- Subscribe to app state changes (active/background) -- Subscribe to network info changes -- Subscribe to active server changes -- Subscribe to WebSocket state per server -- Subscribe to performance state per server - -**Public API**: -```typescript -class NetworkConnectivitySubscriptionManager { - // Initialize all subscriptions - init(): void - - // Stop subscriptions (preserves AppState listener) - stop(): void - - // Complete shutdown - shutdown(): void -} -``` - -### 4. ClientTracking (Enhanced) - -**Purpose**: Integrate performance tracking into network requests - -**Integration Points**: -```typescript -class ClientTracking { - doFetchWithTracking = async (url: string, options: ClientOptions) => { - // 1. Start performance tracking - const performanceRequestId = this.startNetworkPerformanceTracking(url); - - try { - // 2. Execute request - response = await request(url, this.buildRequestOptions(options)); - - // 3. Complete tracking with metrics - this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics); - } catch (error) { - // 4. Cancel tracking on error - this.cancelNetworkPerformanceTracking(performanceRequestId); - throw error; - } - } -} -``` - ---- - -## Data Flow - -### 1. Request Performance Tracking Flow - -```mermaid -flowchart TD - Start[HTTP Request Starts] --> CT[ClientTracking.doFetchWithTracking] - CT -->|startNetworkPerformanceTracking| NPM[NetworkPerformanceManager] - - NPM -->|1. Generate unique requestId
2. Set 2s timer
3. Store in activeRequests| Decision{Request Outcome} - - Decision -->|Completes < 2s| Complete[Record Outcome
wasEarly: false] - Decision -->|Timer Triggers @ 2s| Early[Early Detection
Record Outcome
wasEarly: true] - Decision -->|Fails| Cancel[Cancel Tracking
No outcome recorded] - - Complete --> Update[Update Performance State] - Early --> Update - - Update --> Steps[1. Add to outcomes window max 20
2. Calculate slow %
3. Determine state
4. Emit if changed] - - style NPM fill:#f0e1ff - style Update fill:#e1f5ff - style Complete fill:#d4edda - style Early fill:#fff3cd - style Cancel fill:#f8d7da -``` - -### 2. Subscription and State Flow - -```mermaid -sequenceDiagram - participant Init as app/init/app.ts - participant NCSM as NetworkConnectivitySubscriptionManager - participant NCM as NetworkConnectivityManager - participant WSM as WebsocketManager - participant NPM as NetworkPerformanceManager - participant DB as Active Servers DB - - Init->>NCM: 1. initialize()
init(activeServerUrl) - Init->>NCSM: 2. start()
init() - - NCSM->>NCSM: AppState.addEventListener() - NCSM->>NCSM: NetInfo.addEventListener() - NCSM->>DB: subscribeActiveServers() - - DB->>NCSM: Active Server Changed
findMostRecentServer() β†’ serverUrl - - NCSM->>NCM: setServerConnectionStatus(true, serverUrl) - NCSM->>WSM: observeWebsocketState(serverUrl) - NCSM->>NPM: observePerformanceState(serverUrl) - - loop State Updates - WSM-->>NCSM: Emit WebSocket State - NCSM->>NCM: updateState(websocketState, netInfo, appState) - NCM->>NCM: updateBanner()
Priority Order:
1. Disconnected
2. Performance
3. Connecting
4. Reconnection
5. Connected - - NPM-->>NCSM: Emit Performance State - NCSM->>NCM: updatePerformanceState(performanceState) - NCM->>NCM: updateBanner() - end -``` - ---- - -## Technical Specifications - -### Performance Detection Algorithm - -#### Constants -```typescript -const SLOW_REQUEST_THRESHOLD = 2000; // 2 seconds -const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7; // 70% -const REQUEST_OUTCOME_WINDOW_SIZE = 20; // Last 20 requests -const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4; // First detection -const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10; // Later detections -``` - -#### State Calculation Logic -```typescript -function calculatePerformanceStateFromOutcomes( - outcomes: RequestOutcome[], - isInitialDetection: boolean -): NetworkPerformanceState { - const minimumRequests = isInitialDetection - ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION // 4 requests - : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; // 10 requests - - // Not enough data yet for initial detection - if (isInitialDetection && outcomes.length < minimumRequests) { - return 'normal'; - } - - const slowRequestCount = outcomes.filter(o => o.isSlow).length; - const slowPercentage = slowRequestCount / outcomes.length; - - // Slow if β‰₯70% of requests are slow - return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD - ? 'slow' - : 'normal'; -} -``` - -#### Early Detection System -```typescript -// When request tracking starts -startRequestTracking(serverUrl: string, url: string): string { - const requestId = generateUniqueId(); - - // Set timer for early detection - const checkTimer = setTimeout(() => { - // If still active after 2s, mark as slow - if (activeRequests[requestId]) { - recordRequestOutcome({ - timestamp: Date.now(), - isSlow: true, - wasEarlyDetection: true // Flag for early detection - }); - clearActiveRequest(requestId); - } - }, SLOW_REQUEST_THRESHOLD); // 2000ms - - activeRequests[requestId] = { id, url, startTime, checkTimer }; - return requestId; -} - -// When request completes -completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics) { - const wasEarlyDetected = !activeRequests[requestId]; // Already removed by timer - - clearActiveRequest(requestId); - - // Only record if not already recorded by early detection - if (!wasEarlyDetected) { - recordRequestOutcome({ - timestamp: Date.now(), - isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD, - wasEarlyDetection: false - }); - } -} -``` - -### Sliding Window Management -```typescript -recordRequestOutcome(serverUrl: string, outcome: RequestOutcome) { - if (!requestOutcomes[serverUrl]) { - requestOutcomes[serverUrl] = []; - } - - requestOutcomes[serverUrl].push(outcome); - - // Keep only last 20 requests - if (requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) { - requestOutcomes[serverUrl] = requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE); - } - - // Recalculate and emit new state - const newState = calculatePerformanceStateFromOutcomes( - requestOutcomes[serverUrl], - isInitialDetection[serverUrl] - ); - - performanceSubject.next(newState); -} -``` - ---- - -## Banner Display Logic - -### Priority Order (First Match Wins) - -```typescript -private updateBanner() { - // 0. Hide banner if no server or app in background - if (!currentServerUrl || appState === 'background') { - BannerManager.hideBanner(); - return; - } - - // 1. HIGHEST PRIORITY: Disconnected state - if (shouldShowDisconnectedBanner(websocketState, isOnAppStart)) { - showConnectivity(false); // Persistent banner - return; - } - - // 2. Performance degradation - if (shouldShowPerformanceBanner(performanceState, performanceSuppressed)) { - showPerformance(10000); // Auto-hide after 10s - return; - } - - // 3. Connecting state - if (shouldShowConnectingBanner(websocketState, isOnAppStart)) { - showConnectivity(false); // Persistent banner - return; - } - - // 4. Reconnection success - if (shouldShowReconnectionBanner(websocketState, previousState, isOnAppStart)) { - showConnectivity(true, 3000); // Auto-hide after 3s - return; - } - - // 5. LOWEST PRIORITY: Connected (hide banner) - BannerManager.hideBanner(); -} -``` - -### Banner Decision Functions - -#### Disconnected Banner -```typescript -shouldShowDisconnectedBanner( - websocketState: WebsocketConnectedState | null, - isOnAppStart: boolean -): boolean { - return websocketState === 'not_connected' && !isOnAppStart; -} -``` -- **Shows**: When disconnected after initial connection -- **Hides**: On first connection (app start) -- **Duration**: Persistent until state changes -- **Messages** (internationalized): - - "The server is not reachable" (`connection_banner.not_reachable`) - when internet is reachable but server is not - - "Unable to connect to network" (`connection_banner.not_connected`) - when internet is not reachable - - "Connection status unknown" (`connection_banner.status_unknown`) - when websocketState or netInfo is null - -#### Performance Banner -```typescript -shouldShowPerformanceBanner( - performanceState: NetworkPerformanceState | null, - performanceSuppressed: boolean -): boolean { - return performanceState === 'slow' && !performanceSuppressed; -} -``` -- **Shows**: When performance degrades to 'slow' -- **Suppression**: User can dismiss; suppressed until performance returns to 'normal' -- **Duration**: Auto-hide after 10 seconds -- **Message**: "Limited network connection" (`connection_banner.limited_network_connection`) - -#### Connecting Banner -```typescript -shouldShowConnectingBanner( - websocketState: WebsocketConnectedState | null, - isOnAppStart: boolean -): boolean { - return websocketState === 'connecting' && !isOnAppStart; -} -``` -- **Shows**: When reconnecting after initial connection -- **Hides**: During first connection -- **Duration**: Persistent until state changes -- **Message**: "Connecting..." (`connection_banner.connecting`) - -#### Reconnection Banner -```typescript -shouldShowReconnectionBanner( - websocketState: WebsocketConnectedState | null, - previousWebsocketState: WebsocketConnectedState | null, - isOnAppStart: boolean -): boolean { - return websocketState === 'connected' && - previousWebsocketState !== 'connected' && - !isOnAppStart; -} -``` -- **Shows**: When connection restored after being disconnected/connecting -- **Hides**: On first connection -- **Duration**: Auto-hide after 3 seconds -- **Message**: "Connection restored" (`connection_banner.connected`) - -### Message Internationalization - -All banner messages are internationalized using `formatMessage()` for proper localization: - -```typescript -private getConnectionMessage(): string { - // Guard against uninitialized state - if (!this.websocketState || !this.netInfo) { - return this.intl.formatMessage({ - id: 'connection_banner.status_unknown', - defaultMessage: 'Connection status unknown' - }); - } - - return getConnectionMessageText( - this.websocketState, - this.netInfo.isInternetReachable, - this.intl.formatMessage - ); -} -``` - -**Message Keys**: -- `connection_banner.status_unknown` - When websocketState or netInfo is null -- `connection_banner.connected` - Connection restored -- `connection_banner.connecting` - Connecting... -- `connection_banner.not_reachable` - Server not reachable (but internet is) -- `connection_banner.not_connected` - Unable to connect to network -- `connection_banner.limited_network_connection` - Performance degraded - -### Suppression Mechanisms - -#### Performance Suppression -```typescript -// User dismisses performance banner -onDismiss: () => { - this.performanceSuppressedUntilNormal = true; -} - -// Reset when performance returns to normal -updatePerformanceState(performanceState: NetworkPerformanceState) { - if (this.performanceSuppressedUntilNormal && performanceState === 'normal') { - this.performanceSuppressedUntilNormal = false; - } -} -``` - -#### First Connection Logic -```typescript -// isOnAppStart flag prevents banners on initial connection -updateState(websocketState, netInfo, appState) { - this.previousWebsocketState = this.websocketState; - this.websocketState = websocketState; - - this.updateBanner(); - - // Clear flag only after first successful connection - if (websocketState === 'connected' && this.isOnAppStart) { - this.isOnAppStart = false; - } -} -``` - ---- - -## Configuration - -### User Preference - -**Low Connectivity Monitor** (User Setting): -- Accessible in: Settings β†’ Advanced Settings β†’ Experimental Features -- Controls real-time performance monitoring and banner display -- Enables early detection and network performance tracking -- **Independent** from `CollectNetworkMetrics` -- Stored in app-level database via `GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR` -- Default: **Enabled** (true) -- Observable via `observeLowConnectivityMonitor()` from `@queries/app/global` - -#### Implementation Details - -**Actions** (`app/actions/app/global.ts`): -```typescript -export const storeLowConnectivityMonitor = async (enabled: boolean) => { - return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false); -}; -``` - -**Queries** (`app/queries/app/global.ts`): -```typescript -export const observeLowConnectivityMonitor = () => { - const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR); - if (!query) { - return of$(true); // Default to enabled - } - return query.observe().pipe( - switchMap((result) => (result.length ? result[0].observe() : of$(true))), - switchMap((v) => { - if (typeof v === 'boolean') { - return of$(v); - } - return of$(v?.value ?? true); - }), - ); -}; -``` - -### Integration in ClientTracking - -```typescript -class ClientTracking { - private lowConnectivityMonitorEnabled = true; - - constructor(apiClient: APIClientInterface) { - this.apiClient = apiClient; - - // Subscribe to user preference changes - observeLowConnectivityMonitor().subscribe((enabled) => { - this.lowConnectivityMonitorEnabled = enabled; - }); - } - - startNetworkPerformanceTracking(url: string): string | undefined { - if (!this.lowConnectivityMonitorEnabled) { - return undefined; - } - return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url); - } - - completeNetworkPerformanceTracking( - requestId: string | undefined, - url: string, - metrics: ClientResponseMetrics | undefined - ) { - if (!this.lowConnectivityMonitorEnabled || !requestId || !metrics) { - return; - } - NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics); - } - - cancelNetworkPerformanceTracking(requestId: string | undefined) { - if (!this.lowConnectivityMonitorEnabled || !requestId) { - return; - } - NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId); - } -} -``` - -**Network metrics collection** (existing - for telemetry): -```typescript -if (groupLabel && CollectNetworkMetrics) { - this.incrementRequestCount(groupLabel); - this.trackRequest(groupLabel, url, response.metrics); - this.decrementRequestCount(groupLabel); -} -``` - -### Network Manager Configuration - -**Metrics Collection** (`app/managers/network_manager.ts`): -```typescript -const config = { - sessionConfiguration: { - 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: true, // Always enabled to support Low Connectivity Monitor - }, - headers, -}; -``` - -**Note**: `collectMetrics` is now always set to `true` to ensure network metrics are available for the Low Connectivity Monitor. The actual usage of these metrics is controlled dynamically by the user's Low Connectivity Monitor preference in `ClientTracking`. - ---- - -## Lifecycle Management - -### App State Transitions - -```typescript -// AppState: active β†’ background -handleAppStateChange(nextAppState: AppStateStatus) { - if (nextAppState !== 'active') { - // 1. Clear all active request timers - clearAllActiveRequests(); - - // 2. Reset performance state to normal - performanceSubjects.forEach(subject => subject.next('normal')); - - // 3. Hide all banners - BannerManager.hideBanner(); - - // 4. Stop subscriptions (except AppState listener) - NetworkConnectivitySubscriptionManager.stop(); - } -} - -// AppState: background β†’ active -handleAppStateChange(nextAppState: AppStateStatus) { - if (nextAppState === 'active') { - // Restart all subscriptions - NetworkConnectivitySubscriptionManager.init(); - } -} -``` - -### Server Lifecycle - -```typescript -// Server added/activated -handleActiveServersChange(servers: Server[]) { - const activeServer = findMostRecentServer(servers); - - if (activeServer) { - // 1. Set connection status - NetworkConnectivityManager.setServerConnectionStatus(true, activeServer.url); - - // 2. Subscribe to WebSocket state - websocketSubscription = WebsocketManager - .observeWebsocketState(activeServer.url) - .subscribe(state => { - NetworkConnectivityManager.updateState(state, netInfo, appState); - }); - - // 3. Subscribe to performance state - performanceSubscription = NetworkPerformanceManager - .observePerformanceState(activeServer.url) - .subscribe(state => { - NetworkConnectivityManager.updatePerformanceState(state); - }); - } -} - -// Server removed/logout -terminateSession(serverUrl: string) { - // 1. Clear connection status - NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl); - - // 2. Remove performance tracking - NetworkPerformanceManager.removeServer(serverUrl); - - // 3. Stop subscriptions - NetworkConnectivitySubscriptionManager.stop(); -} -``` - ---- - -## Performance Characteristics - -### Memory Usage -- **Request Outcomes**: Max 20 per server (sliding window) -- **Active Requests**: Cleared on completion or 2s timeout -- **Timers**: One per active request, auto-cleared - -### Detection Speed -- **Early Detection**: 2 seconds (timer-based) -- **Initial Detection**: 4-8 slow requests (4 minimum) -- **Subsequent Detection**: 10-20 requests (10 minimum with 70% threshold) - -### Banner Timing -- **Disconnected**: Immediate, persistent -- **Connecting**: Immediate, persistent -- **Performance**: After threshold met, auto-hide 10s -- **Reconnected**: Immediate, auto-hide 3s - ---- - -## Error Handling - -### Request Failure -```typescript -try { - response = await request(url, options); - completeRequestTracking(requestId, response.metrics); -} catch (error) { - // Cancel tracking - no outcome recorded - cancelRequestTracking(requestId); - throw error; -} -``` - -### Missing Metrics -```typescript -completeRequestTracking(requestId, metrics) { - if (!metrics) { - // Silently skip - no outcome recorded - clearActiveRequest(requestId); - return; - } - // ... record outcome -} -``` - -### Subscription Cleanup -```typescript -// Safe to call multiple times -stop() { - websocketSubscription?.unsubscribe(); - performanceSubscription?.unsubscribe(); - netInfoSubscription?.(); - activeServersUnsubscriber?.unsubscribe(); - - // Clear references - websocketSubscription = undefined; - performanceSubscription = undefined; - // ... -} -``` - ---- - -## Testing Strategy - -### Unit Tests -- **NetworkPerformanceManager**: Request tracking, state calculation, sliding window -- **NetworkConnectivityManager**: Banner decision functions, state transitions -- **NetworkConnectivitySubscriptionManager**: Subscription lifecycle, server changes - -### Test Coverage -- Early detection (timer triggers) -- Request completion before/after threshold -- State transitions (normal ↔ slow) -- Banner priority order -- Suppression mechanisms -- App lifecycle (background/foreground) -- Server lifecycle (add/remove) -- Error scenarios (missing metrics, failed requests) - ---- - -## Future Enhancements - -### Potential Improvements -1. **Adaptive Thresholds**: Adjust SLOW_REQUEST_THRESHOLD based on historical performance -2. **Network Type Awareness**: Different thresholds for WiFi vs Cellular -3. **Exponential Backoff**: For repeated performance banners -4. **Metrics Reporting**: Send detection events to analytics -5. **User Preferences**: Allow users to configure sensitivity - -### Known Limitations -1. **Single Server Focus**: Only monitors most recently active server -2. **Fixed Thresholds**: 2s threshold may not suit all use cases -3. **No Geographical Context**: Doesn't account for server location -4. **Binary States**: Only 'normal' vs 'slow' (no 'excellent' or 'poor') diff --git a/fastlane/.env.android.pr b/fastlane/.env.android.pr index 579ee91c1..9eb87d8b3 100644 --- a/fastlane/.env.android.pr +++ b/fastlane/.env.android.pr @@ -9,6 +9,5 @@ BUILD_FOR_RELEASE=true BUILD_PR=true COLLECT_NETWORK_METRICS=true MAIN_APP_IDENTIFIER=com.mattermost.rnbeta -MONITOR_NETWORK_PERFORMANCE=true REPLACE_ASSETS=false SHOW_ONBOARDING=true \ No newline at end of file diff --git a/fastlane/.env.ios.pr b/fastlane/.env.ios.pr index f34353fcd..29c813ed7 100644 --- a/fastlane/.env.ios.pr +++ b/fastlane/.env.ios.pr @@ -20,7 +20,6 @@ MATCH_READONLY=true MATCH_SHALLOW_CLONE=true MATCH_SKIP_DOCS=true MATCH_TYPE=adhoc -MONITOR_NETWORK_PERFORMANCE=true NOTIFICATION_SERVICE_IDENTIFIER=com.mattermost.rnbeta.NotificationService PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING=true REPLACE_ASSETS=false diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f30af36f..f3008b103 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -186,11 +186,6 @@ lane :configure do json['CollectNetworkMetrics'] = true end - # Monitor network performance - if ENV['MONITOR_NETWORK_PERFORMANCE'] == 'true' - json['MonitorNetworkPerformance'] = true - end - # Configure the auth schemes auth_scheme = ENV['APP_AUTH_SCHEME'].to_s.empty? ? 'mmauth' : ENV['APP_AUTH_SCHEME'] auth_scheme_dev = ENV['APP_AUTH_SCHEME_BETA'].to_s.empty? ? 'mmauthbeta' : ENV['APP_AUTH_SCHEME_BETA'] diff --git a/floating-banner.md b/floating-banner.md deleted file mode 100644 index ba58533b3..000000000 --- a/floating-banner.md +++ /dev/null @@ -1,1165 +0,0 @@ -# Floating Banner System - -## Overview - -The Floating Banner system provides a comprehensive solution for displaying temporary notifications, alerts, and messages in the Mattermost mobile application. It consists of multiple components working together to deliver a smooth, accessible, and highly customizable banner experience. - -## System Architecture - -```mermaid -graph TB - subgraph "Application Layer" - NC[NetworkConnectivityManager] - APP[App Components] - INIT[App Initialization] - end - - subgraph "Banner Management Layer" - BM[BannerManager
Singleton] - end - - subgraph "UI Layer" - OVL[Navigation Overlay
System] - FB[FloatingBanner
Component] - B[Banner
Component] - BI[BannerItem
Component] - CB[ConnectionBanner
Component] - end - - subgraph "Core Components" - GH[GestureHandler] - RA[React Native
Reanimated] - SA[Safe Area] - end - - NC -->|Network Events| BM - APP -->|Show/Hide Requests| BM - INIT -->|System Setup| BM - - BM -->|Overlay Management| OVL - - OVL -->|Render| FB - FB -->|Position & Layout| B - B -->|Content| BI - B -->|Custom Content| CB - - B -->|Animations| RA - B -->|Gestures| GH - B -->|Layout| SA - - style BM fill:#e1f5fe - style FB fill:#f3e5f5 - style B fill:#e8f5e8 -``` - -## Component Architecture - -### 1. BannerManager (Singleton) - -**Purpose**: Central controller for banner lifecycle management - -```mermaid -stateDiagram-v2 - [*] --> Hidden: Initial State - Hidden --> Showing: showBanner() - - Showing --> AutoHiding: showBannerWithAutoHide() - Showing --> Hidden: hideBanner(bannerId) / User Dismiss - - AutoHiding --> Hidden: Timeout Expires - AutoHiding --> Hidden: hideBanner(bannerId) / User Dismiss - - Hidden --> [*]: cleanup() - Showing --> [*]: cleanup() - AutoHiding --> [*]: cleanup() -``` - -**Key Features**: -- Singleton pattern with support for multiple stacked banners in a single overlay -- Per-banner auto-hide timers for independent timeout management -- **Promise-chain queue system** to prevent race conditions (replaces previous UpdateState enum) -- 2-second overlay dismiss delay to prevent flickering during rapid banner changes -- Overlay system integration with `Navigation.updateProps` for efficient updates -- Error handling for dismiss callbacks -- State tracking (active banners array, overlay visibility, individual timers) -- **Required `bannerId` parameter** for `hideBanner()` to prevent accidental cross-system interference - -**Android Limitation**: -- On Android, when both top AND bottom banners are displayed simultaneously, only ONE GestureHandlerRootView can properly register touch events -- iOS works correctly with simultaneous top/bottom banners -- Workaround not yet implemented - future enhancement may add banner position prioritization on Android - -### 2. FloatingBanner Component - -**Purpose**: Main rendering component that splits banners by position and delegates to BannerSection - -```mermaid -flowchart TD - FB[FloatingBanner] --> CHECK{Banners exist?} - CHECK -->|No| NULL[Return null] - CHECK -->|Yes| SPLIT[Split by position] - - SPLIT --> TOP[Top Banners] - SPLIT --> BOTTOM[Bottom Banners] - - TOP --> BSTOP[BannerSection 'top'] - BOTTOM --> BSBOTTOM[BannerSection 'bottom'] - - BSTOP --> GHRTOP[GestureHandlerRootView
positioned at top] - BSBOTTOM --> GHRBOTTOM[GestureHandlerRootView
positioned at bottom] - - GHRTOP --> ABTOP[AnimatedBannerItem
Components] - GHRBOTTOM --> ABBOTTOM[AnimatedBannerItem
Components] - - ABTOP --> CONTENT[Banner Content] - ABBOTTOM --> CONTENT - - CONTENT --> BI[BannerItem] - CONTENT --> CUSTOM[Custom Content] -``` - -### 3. BannerSection Component - -**Purpose**: Positions and sizes the GestureHandlerRootView for a banner section (top or bottom) - -**Key Features**: -- Creates a `GestureHandlerRootView` for each section (top/bottom) -- Uses `useBannerGestureRootPosition` hook for platform-specific positioning -- Calculates container height based on number of banners -- Applies safe area insets for top banners -- Handles keyboard adjustments (iOS only) -- Returns `null` when no banners in section - -```mermaid -graph LR - subgraph "BannerSection Features" - POS["Position Calculation
β€’ useBannerGestureRootPosition hook
β€’ Container height calculation
β€’ Safe area insets (top only)
β€’ Keyboard-aware (iOS)"] - GESTURE["Gesture Root
β€’ GestureHandlerRootView
β€’ pointerEvents='box-none'
β€’ Positioned absolutely"] - RENDER["Banner Rendering
β€’ AnimatedBannerItem per banner
β€’ Swipe to dismiss gestures
β€’ Stacked with spacing"] - end - - POS --> GESTURE - GESTURE --> RENDER -``` - -### 4. useBannerGestureRootPosition Hook - -**Purpose**: Calculates positioning and sizing for GestureHandlerRootView based on platform, device type, and keyboard state - -**Key Features**: -- Platform-specific bottom offsets (Android vs iOS) -- Tablet-specific width constraints (96% of available width after sidebar) -- Keyboard-aware positioning (iOS dynamically adjusts, Android uses fixed offset) -- Memoized for performance -- Exports `BANNER_TABLET_WIDTH_PERCENTAGE` constant via `testExports` - -## Data Flow - -### Banner Lifecycle - -```mermaid -sequenceDiagram - participant App as Application Code - participant BM as BannerManager - participant Overlay as Navigation Overlay - participant FB as FloatingBanner - participant B as Banner - participant User as User Interaction - - App->>BM: showBanner(config) - BM->>BM: Add to activeBanners[] - BM->>BM: updateOverlay() - - alt First Banner (overlay not visible) - BM->>Overlay: showOverlay(FLOATING_BANNER) - BM->>BM: overlayVisible = true - else Additional Banner (overlay exists) - BM->>Overlay: Navigation.updateProps(banners) - end - - Overlay->>FB: Render with banners array - FB->>FB: Split banners by position - FB->>B: Render individual banners with offset - B->>B: Calculate positioning - B->>B: Apply animations - - User->>B: Swipe to dismiss - B->>BM: handleDismiss(bannerId) - BM->>BM: Remove from activeBanners[] - BM->>BM: Clear banner's timer - - alt Last banner removed - BM->>Overlay: Navigation.updateProps(empty array) - Note over BM,Overlay: UI clears immediately - BM->>BM: Wait 2s (dismiss delay) - Note over BM: Prevents flickering on rapid changes - BM->>Overlay: dismissOverlay() - BM->>BM: overlayVisible = false - else Other banners remain - BM->>Overlay: Navigation.updateProps(remaining banners) - end -``` - -### Network Connectivity Integration - -```mermaid -sequenceDiagram - participant NCM as NetworkConnectivityManager - participant BM as BannerManager - participant CB as ConnectionBanner - - NCM->>NCM: Network state change - NCM->>NCM: updateBanner() - - alt Disconnected State - NCM->>BM: showBanner(disconnected config) - BM->>CB: Render disconnected banner - else Performance Issues - NCM->>BM: showBanner(performance config) - BM->>CB: Render performance banner - else Connected - NCM->>BM: hideBanner() - end -``` - -## API Reference - -### BannerConfig Interface - -```typescript -interface BannerConfig { - id: string; // Unique identifier - title: string; // Banner title text - message: string; // Banner message text - type?: 'info' | 'success' | 'warning' | 'error'; // Visual styling - dismissible?: boolean; // Can user dismiss (default: true) - autoHideDuration?: number; // Auto-hide timeout in ms - position?: 'top' | 'bottom'; // Screen position (default: 'top') - onPress?: () => void; // Tap handler - onDismiss?: () => void; // Dismiss handler - customComponent?: ReactNode; // Custom banner component -} -``` - -### BannerManager API - -```typescript -class BannerManager { - // Show banner immediately (stacks with existing banners) - showBanner(config: BannerConfig): void; - - // Show banner with auto-hide (per-banner timer) - showBannerWithAutoHide(config: BannerConfig, durationMs?: number): void; - - // Hide specific banner by ID (required to prevent accidental cross-system interference) - hideBanner(bannerId: string): void; - - // Hide all banners at once - hideAllBanners(): void; - - // Clean up all timeouts and state - cleanup(): void; - - // Get most recently added banner ID - getCurrentBannerId(): string | null; - - // Check if any banner is visible - isBannerVisible(): boolean; -} -``` - -### Internal State Management - -```typescript -private activeBanners: FloatingBannerConfig[] = []; // Array of active banners -private overlayVisible = false; // Tracks overlay state -private autoHideTimers: Map = new Map(); // Per-banner timers -private updateQueue: Promise = Promise.resolve(); // Promise-chain queue -private dismissOverlayTimer: NodeJS.Timeout | null = null; // 2s dismiss delay -private dismissOverlayResolve: (() => void) | null = null; // Delay cancellation -``` - -**Note**: The previous `UpdateState` enum has been replaced with a promise-chain queue system for better handling of concurrent updates. - -## Usage Patterns - -### 1. Basic Banner Display - -```typescript -import {BannerManager} from '@managers/banner_manager'; - -// Simple info banner -BannerManager.showBanner({ - id: 'welcome-message', - title: 'Welcome!', - message: 'Thanks for using Mattermost', - type: 'success' -}); -``` - -### 2. Auto-hiding Banner - -```typescript -// Show banner for 3 seconds (with per-banner timer) -BannerManager.showBannerWithAutoHide({ - id: 'temp-notification', - title: 'Message Sent', - message: 'Your message was delivered successfully', - type: 'success' -}, 3000); - -// Multiple auto-hide banners work independently -BannerManager.showBannerWithAutoHide({ - id: 'banner-1', - title: 'First', - message: 'Auto-hides in 5s', - type: 'info' -}, 5000); - -BannerManager.showBannerWithAutoHide({ - id: 'banner-2', - title: 'Second', - message: 'Auto-hides in 3s', - type: 'success' -}, 3000); -// Both banners stack and auto-hide independently -``` - -### 3. Network Status Banner - -```typescript -// In NetworkConnectivityManager -private showDisconnectedBanner() { - BannerManager.showBanner({ - id: 'network-disconnected', - title: 'No Connection', - message: 'Check your internet connection', - type: 'error', - position: 'bottom', - customComponent: ( - this.handleBannerDismiss()} - /> - ) - }); -} -``` - -### 4. Custom Component Banner - -```typescript -// Custom banner with complex content -BannerManager.showBanner({ - id: 'custom-banner', - title: 'Custom', - message: 'Custom message', - customComponent: ( - - Custom banner content -