revert: floating banner & low connectivity (#9254)
* Revert "feat(MM-65625): low connectivity feature toggle in advance settings (#9191)" This reverts commitc872e2e2b1. * Revert "feat(MM-65145): Network connectivity/performance observer (#9173)" This reverts commit597e03d7d8. * Revert "feat(MM-65625): floating banner (#9162)" This reverts commit5887c3ab46. * Revert "feat(MM-65625): enhance navigation overlay management (#9165)" This reverts commitf1554f0341. * revert advanced.test.tsx changes
This commit is contained in:
parent
1d8568ade8
commit
72b97f60f5
61 changed files with 226 additions and 9184 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof useBanner>['swipeGesture'],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children and animated view', () => {
|
||||
render(
|
||||
<Banner>
|
||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
||||
</Banner>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
||||
expect(screen.getByTestId('banner-animated-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not use GestureDetector when not dismissible', () => {
|
||||
render(
|
||||
<Banner dismissible={false}>
|
||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
||||
</Banner>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('gesture-detector')).toBeNull();
|
||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses GestureDetector when dismissible', () => {
|
||||
render(
|
||||
<Banner dismissible={true}>
|
||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
||||
</Banner>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('gesture-detector')).toBeTruthy();
|
||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('passes correct props to useBanner hook', () => {
|
||||
const onDismiss = jest.fn();
|
||||
|
||||
render(
|
||||
<Banner
|
||||
animationDuration={300}
|
||||
dismissible={true}
|
||||
onDismiss={onDismiss}
|
||||
swipeThreshold={150}
|
||||
>
|
||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
||||
</Banner>,
|
||||
);
|
||||
|
||||
expect(mockUseBanner).toHaveBeenCalledWith({
|
||||
animationDuration: 300,
|
||||
dismissible: true,
|
||||
swipeThreshold: 150,
|
||||
onDismiss,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default prop values when not provided', () => {
|
||||
render(
|
||||
<Banner>
|
||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
||||
</Banner>,
|
||||
);
|
||||
|
||||
expect(mockUseBanner).toHaveBeenCalledWith({
|
||||
animationDuration: 250,
|
||||
dismissible: false,
|
||||
swipeThreshold: 100,
|
||||
onDismiss: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
* <Banner
|
||||
* dismissible={true}
|
||||
* onDismiss={() => console.log('Banner dismissed!')}
|
||||
* >
|
||||
* <Text>Your banner content</Text>
|
||||
* </Banner>
|
||||
* ```
|
||||
*/
|
||||
const Banner: React.FC<BannerProps> = ({
|
||||
children,
|
||||
animationDuration = 250,
|
||||
style,
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
swipeThreshold = 100,
|
||||
}) => {
|
||||
const {animatedStyle, swipeGesture} = useBanner({
|
||||
animationDuration,
|
||||
dismissible,
|
||||
swipeThreshold,
|
||||
onDismiss,
|
||||
});
|
||||
|
||||
const bannerContent = (
|
||||
<Animated.View
|
||||
testID='banner-animated-view'
|
||||
style={[
|
||||
styles.wrapper,
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
if (dismissible) {
|
||||
return (
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
{bannerContent}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
return bannerContent;
|
||||
};
|
||||
|
||||
export default Banner;
|
||||
|
|
@ -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> = {}): 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(
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
onPress={props.onPress}
|
||||
onDismiss={props.onDismiss}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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 <html> & "quotes" and more émojis 🚀',
|
||||
});
|
||||
renderBannerItem(banner);
|
||||
|
||||
expect(screen.getByText('Title with émojis 🎉 and spéciäl chars')).toBeTruthy();
|
||||
expect(screen.getByText('Message with <html> & "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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<BannerItemProps> = ({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 (
|
||||
<Pressable
|
||||
testID={`banner-item-${banner.id}`}
|
||||
style={({pressed}) => [
|
||||
styles.container,
|
||||
styles[banner.type || 'info'],
|
||||
pressed && (banner.onPress || onPress) && styles.pressed,
|
||||
]}
|
||||
onPress={handlePress}
|
||||
disabled={!banner.onPress && !onPress}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<CompassIcon
|
||||
name={getBannerIconName(banner.type || 'info')}
|
||||
size={16}
|
||||
color={getBannerIconColor(banner.type || 'info', theme)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={styles.content}
|
||||
testID={`banner-content-${banner.id}`}
|
||||
>
|
||||
{banner.title && <Text style={styles.title}>{banner.title}</Text>}
|
||||
{banner.message && <Text style={styles.message}>{banner.message}</Text>}
|
||||
</View>
|
||||
|
||||
{banner.dismissible !== false && onDismiss && (
|
||||
<Pressable
|
||||
testID={`banner-dismiss-${banner.id}`}
|
||||
style={({pressed}) => [
|
||||
styles.dismissButton,
|
||||
pressed && styles.dismissPressed,
|
||||
]}
|
||||
onPress={handleDismiss}
|
||||
>
|
||||
<CompassIcon
|
||||
name={'close'}
|
||||
size={14}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerItem;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -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<ViewStyle>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
|
@ -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<Props> = ({isConnected, message, dismissible = false, onDismiss}) => {
|
||||
const clearTimeoutRef = (ref: React.MutableRefObject<NodeJS.Timeout | null | undefined>) => {
|
||||
if (ref.current) {
|
||||
clearTimeout(ref.current);
|
||||
ref.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const TIME_TO_OPEN = toMilliseconds({seconds: 3});
|
||||
const TIME_TO_CLOSE = toMilliseconds({seconds: 1});
|
||||
|
||||
const ConnectionBanner = ({
|
||||
websocketState,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const closeTimeout = useRef<NodeJS.Timeout | null>();
|
||||
const openTimeout = useRef<NodeJS.Timeout | null>();
|
||||
const height = useSharedValue(0);
|
||||
const theme = useTheme();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const style = getStyle(theme);
|
||||
const appState = useAppState();
|
||||
const netInfo = useNetInfo();
|
||||
|
||||
const 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 (
|
||||
<View
|
||||
style={[
|
||||
style.container,
|
||||
isConnected ? style.containerConnected : style.containerNotConnected,
|
||||
]}
|
||||
<Animated.View
|
||||
style={[style.background, bannerStyle]}
|
||||
>
|
||||
<View style={style.content}>
|
||||
<CompassIcon
|
||||
color={theme.centerChannelBg}
|
||||
name={isConnected ? 'check' : 'information-outline'}
|
||||
size={16}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text
|
||||
style={style.text}
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
<View
|
||||
style={isConnected ? style.bannerContainerConnected : style.bannerContainerNotConnected}
|
||||
>
|
||||
{visible &&
|
||||
<View
|
||||
style={style.wrapper}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
<Text
|
||||
style={style.bannerTextContainer}
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
>
|
||||
<CompassIcon
|
||||
color={theme.centerChannelBg}
|
||||
name={isConnected ? 'check' : 'information-outline'}
|
||||
size={18}
|
||||
/>
|
||||
{' '}
|
||||
<Text style={style.bannerText}>
|
||||
{text}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
|
||||
{dismissible && onDismiss && (
|
||||
<Pressable
|
||||
style={({pressed}) => [
|
||||
style.dismissButton,
|
||||
pressed && style.dismissPressed,
|
||||
]}
|
||||
onPress={onDismiss}
|
||||
>
|
||||
<CompassIcon
|
||||
name={'close'}
|
||||
size={14}
|
||||
color={changeOpacity(theme.centerChannelBg, 0.7)}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
15
app/components/connection_banner/index.ts
Normal file
15
app/components/connection_banner/index.ts
Normal file
|
|
@ -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));
|
||||
|
|
@ -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<string, unknown>, 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> = {}): 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(
|
||||
<AnimatedBannerItem
|
||||
banner={banner}
|
||||
index={index}
|
||||
onBannerPress={mockOnBannerPress}
|
||||
onBannerDismiss={mockOnBannerDismiss}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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 = <Text testID={'custom-content'}>{'Custom Banner Content'}</Text>;
|
||||
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 = <Text testID={'custom-content-bottom'}>{'Bottom Custom'}</Text>;
|
||||
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 = <Text testID={'custom-no-text'}>{'Custom Banner'}</Text>;
|
||||
const banner = createMockBanner({title: undefined, message: undefined, customComponent});
|
||||
renderAnimatedBannerItem(banner);
|
||||
|
||||
expect(screen.getByTestId('custom-no-text')).toBeDefined();
|
||||
expect(screen.queryByTestId('banner-item')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<AnimatedBannerItemProps> = ({
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
styles.bannerContainer,
|
||||
{marginTop: index > 0 ? BANNER_SPACING : 0},
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.animatedBannerWrapper, animatedStyle]}>
|
||||
<Banner
|
||||
dismissible={dismissible}
|
||||
onDismiss={handleDismiss}
|
||||
>
|
||||
{customComponent || (
|
||||
<BannerItem
|
||||
banner={banner}
|
||||
onPress={onBannerPress}
|
||||
onDismiss={onBannerDismiss}
|
||||
/>
|
||||
)}
|
||||
</Banner>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedBannerItem;
|
||||
|
||||
|
|
@ -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<string, unknown>, 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> = {}): 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(
|
||||
<BannerSection
|
||||
sectionBanners={sectionBanners}
|
||||
position={position}
|
||||
onBannerPress={mockOnBannerPress}
|
||||
onBannerDismiss={mockOnBannerDismiss}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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<BannerSectionProps> = ({
|
||||
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 (
|
||||
<GestureHandlerRootView
|
||||
style={[styles.gestureRoot, gestureRootStyle]}
|
||||
pointerEvents='box-none'
|
||||
>
|
||||
<Animated.View
|
||||
testID={testID}
|
||||
style={[styles.containerBase, containerStyle, animatedStyle]}
|
||||
>
|
||||
{sectionBanners.map((banner, index) => (
|
||||
<AnimatedBannerItem
|
||||
key={banner.id}
|
||||
banner={banner}
|
||||
index={index}
|
||||
onBannerPress={onBannerPress}
|
||||
onBannerDismiss={onBannerDismiss}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerSection;
|
||||
|
||||
|
|
@ -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<string, unknown>, 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> = {}): 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(
|
||||
<FloatingBanner
|
||||
banners={banners}
|
||||
onDismiss={mockOverlayOnDismiss}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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: <Text testID={'custom-banner'}>{'Custom'}</Text>,
|
||||
}),
|
||||
];
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<FloatingBannerProps> = ({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 (
|
||||
<>
|
||||
<BannerSection
|
||||
sectionBanners={topBanners}
|
||||
position='top'
|
||||
onBannerPress={onBannerPress}
|
||||
onBannerDismiss={onBannerDismiss}
|
||||
/>
|
||||
<BannerSection
|
||||
sectionBanners={bottomBanners}
|
||||
position='bottom'
|
||||
onBannerPress={onBannerPress}
|
||||
onBannerDismiss={onBannerDismiss}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingBanner;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>([
|
|||
REVIEW_APP,
|
||||
SNACK_BAR,
|
||||
GENERIC_OVERLAY,
|
||||
FLOATING_BANNER,
|
||||
]);
|
||||
|
||||
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<View>, 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<number, TimeoutCallback>();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, NodeJS.Timeout> = new Map();
|
||||
private updateQueue: Promise<void> = 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<void>((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<Record<string, unknown>> = {
|
||||
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,
|
||||
};
|
||||
|
|
@ -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<typeof BannerManager>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<typeof AppState>;
|
||||
const mockNetInfo = NetInfo as jest.Mocked<typeof NetInfo>;
|
||||
const mockSubscribeActiveServers = subscribeActiveServers as jest.MockedFunction<typeof subscribeActiveServers>;
|
||||
const mockNetworkConnectivityManager = NetworkConnectivityManager as jest.Mocked<typeof NetworkConnectivityManager>;
|
||||
const mockNetworkPerformanceManager = NetworkPerformanceManager as jest.Mocked<typeof NetworkPerformanceManager>;
|
||||
const mockWebsocketManager = WebsocketManager as jest.Mocked<typeof WebsocketManager>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> => {
|
||||
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,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<typeof NetworkPerformanceManagerSingleton>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, BehaviorSubject<NetworkPerformanceState>> = {};
|
||||
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
|
||||
private requestOutcomes: Record<string, RequestOutcome[]> = {};
|
||||
private totalRequestCount: Record<string, number> = {};
|
||||
private initialRequestTimestamp: Record<string, number> = {};
|
||||
private isInitialDetection: Record<string, boolean> = {};
|
||||
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<string, unknown> = {
|
||||
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<NetworkPerformanceState>('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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<FloatingBannerScreenProps> = (props) => {
|
||||
return <FloatingBanner {...props}/>;
|
||||
};
|
||||
|
||||
export default FloatingBannerScreen;
|
||||
|
|
@ -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'
|
||||
>
|
||||
<ConnectionBanner/>
|
||||
{props.isLicensed &&
|
||||
<AnnouncementBanner/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
||||
expect(Navigation.showModal).toHaveBeenCalledWith({
|
||||
stack: {
|
||||
|
|
@ -38,7 +33,7 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record<string
|
|||
expectShowModalCalledWith(screen, '', props);
|
||||
}
|
||||
|
||||
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof navigationModule.openAsBottomSheet>, isTablet: boolean) {
|
||||
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component library option in dev mode', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
|
|
@ -270,99 +260,6 @@ describe('AdvancedSettings', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('low connectivity monitor toggle', () => {
|
||||
it('should render toggle in off state when disabled', async () => {
|
||||
renderWithIntlAndTheme(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>);
|
||||
|
||||
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(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={true}
|
||||
/>);
|
||||
|
||||
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(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>);
|
||||
|
||||
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(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={true}
|
||||
/>);
|
||||
|
||||
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(
|
||||
<AdvancedSettings
|
||||
{...defaultProps}
|
||||
lowConnectivityMonitorEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<AdvancedSettings {...defaultProps}/>);
|
||||
|
|
@ -444,21 +341,5 @@ describe('AdvancedSettings', () => {
|
|||
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render experimental features section header', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Experimental Features')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display low connectivity monitor description', async () => {
|
||||
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Display banners when network connectivity or performance issues are detected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number | undefined>(0);
|
||||
const [files, setFiles] = useState<FileInfo[]>(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'
|
||||
/>
|
||||
{/* <SettingSeparator/> */}
|
||||
<SettingSeparator/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<SettingBlock
|
||||
headerText={defineMessage({
|
||||
id: 'settings.advanced.experimental_features',
|
||||
defaultMessage: 'Experimental Features',
|
||||
})}
|
||||
>
|
||||
<SettingSeparator/>
|
||||
|
||||
<SettingOption
|
||||
action={onToggleLowConnectivityMonitor}
|
||||
label={intl.formatMessage({id: 'settings.advanced.low_connectivity_monitor', defaultMessage: 'Low Connectivity Monitor'})}
|
||||
description={intl.formatMessage({
|
||||
id: 'settings.advanced.low_connectivity_monitor.description',
|
||||
defaultMessage: 'Display banners when network connectivity or performance issues are detected',
|
||||
})}
|
||||
selected={isLowConnectivityMonitorEnabled}
|
||||
testID='advanced_settings.low_connectivity_monitor.option'
|
||||
type='toggle'
|
||||
/>
|
||||
<SettingSeparator/>
|
||||
</SettingBlock>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,8 +29,5 @@
|
|||
|
||||
"ExperimentalNormalizeMarkdownLinks": false,
|
||||
"CustomRequestHeaders": {},
|
||||
"CollectNetworkMetrics": false,
|
||||
"MonitorNetworkPerformance": true
|
||||
"CollectNetworkMetrics": false
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<br/>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<br/>• Request Tracking<br/>• Performance Detection<br/>• Observable Streams<br/>• Sliding Window 20 requests"]
|
||||
|
||||
NCM["NetworkConnectivityManager<br/>• updateState()<br/>• updatePerformanceState()<br/>• Banner Priority Logic<br/>• Suppression Control"]
|
||||
|
||||
BM[BannerManager<br/>• Show/Hide Banner<br/>• Auto-Hide<br/>• Cleanup]
|
||||
|
||||
CB[ConnectionBanner<br/>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<NetworkPerformanceState>
|
||||
|
||||
// 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<br/>2. Set 2s timer<br/>3. Store in activeRequests| Decision{Request Outcome}
|
||||
|
||||
Decision -->|Completes < 2s| Complete[Record Outcome<br/>wasEarly: false]
|
||||
Decision -->|Timer Triggers @ 2s| Early[Early Detection<br/>Record Outcome<br/>wasEarly: true]
|
||||
Decision -->|Fails| Cancel[Cancel Tracking<br/>No outcome recorded]
|
||||
|
||||
Complete --> Update[Update Performance State]
|
||||
Early --> Update
|
||||
|
||||
Update --> Steps[1. Add to outcomes window max 20<br/>2. Calculate slow %<br/>3. Determine state<br/>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()<br/>init(activeServerUrl)
|
||||
Init->>NCSM: 2. start()<br/>init()
|
||||
|
||||
NCSM->>NCSM: AppState.addEventListener()
|
||||
NCSM->>NCSM: NetInfo.addEventListener()
|
||||
NCSM->>DB: subscribeActiveServers()
|
||||
|
||||
DB->>NCSM: Active Server Changed<br/>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()<br/>Priority Order:<br/>1. Disconnected<br/>2. Performance<br/>3. Connecting<br/>4. Reconnection<br/>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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
1165
floating-banner.md
1165
floating-banner.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
|
||||
import {setGenerator} from '@nozbe/watermelondb/utils/common/randomId';
|
||||
import * as ReactNative from 'react-native';
|
||||
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
|
||||
|
|
@ -262,8 +264,6 @@ jest.doMock('react-native', () => {
|
|||
addListener: jest.fn(() => ({
|
||||
remove: jest.fn(),
|
||||
})),
|
||||
isVisible: jest.fn(() => false),
|
||||
metrics: jest.fn(() => null),
|
||||
};
|
||||
|
||||
return Object.setPrototypeOf({
|
||||
|
|
@ -342,23 +342,22 @@ jest.mock('react-native-navigation', () => {
|
|||
RNN.Navigation.setLazyComponentRegistrator = jest.fn();
|
||||
RNN.Navigation.setDefaultOptions = jest.fn();
|
||||
RNN.Navigation.registerComponent = jest.fn();
|
||||
|
||||
const mockSubscription = {remove: jest.fn()};
|
||||
const createMockEvents = () => ({
|
||||
registerAppLaunchedListener: jest.fn(() => mockSubscription),
|
||||
registerComponentListener: jest.fn(() => mockSubscription),
|
||||
bindComponent: jest.fn(() => mockSubscription),
|
||||
registerNavigationButtonPressedListener: jest.fn(() => mockSubscription),
|
||||
registerScreenPoppedListener: jest.fn(() => mockSubscription),
|
||||
registerCommandListener: jest.fn(() => mockSubscription),
|
||||
registerComponentWillAppearListener: jest.fn(() => mockSubscription),
|
||||
});
|
||||
|
||||
return {
|
||||
...RNN,
|
||||
Navigation: {
|
||||
...RNN.Navigation,
|
||||
events: jest.fn(createMockEvents),
|
||||
events: () => ({
|
||||
registerAppLaunchedListener: jest.fn(),
|
||||
registerComponentListener: jest.fn(() => {
|
||||
return {remove: jest.fn()};
|
||||
}),
|
||||
bindComponent: jest.fn(() => {
|
||||
return {remove: jest.fn()};
|
||||
}),
|
||||
registerNavigationButtonPressedListener: jest.fn(() => {
|
||||
return {remove: jest.fn()};
|
||||
}),
|
||||
}),
|
||||
setRoot: jest.fn(),
|
||||
pop: jest.fn(),
|
||||
push: jest.fn(),
|
||||
|
|
@ -368,8 +367,7 @@ jest.mock('react-native-navigation', () => {
|
|||
popToRoot: jest.fn(),
|
||||
mergeOptions: jest.fn(),
|
||||
showOverlay: jest.fn(),
|
||||
dismissOverlay: jest.fn(() => Promise.resolve()),
|
||||
dismissAllOverlays: jest.fn(() => Promise.resolve()),
|
||||
dismissOverlay: jest.fn(),
|
||||
updateProps: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
|
@ -397,7 +395,7 @@ jest.mock('react-native-notifications', () => {
|
|||
ios: {
|
||||
getDeliveredNotifications: jest.fn().mockImplementation(() => Promise.resolve(deliveredNotifications)),
|
||||
removeDeliveredNotifications: jest.fn((ids) => {
|
||||
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
deliveredNotifications = deliveredNotifications.filter((n) => !ids.includes(n.identifier));
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue