mattermost-mobile/app/hooks/useBannerGestureRootPosition.test.ts
Rahim Rahman 5887c3ab46
feat(MM-65625): floating banner (#9162)
* feat: implement floating banner system

- Add FloatingBanner component with gesture support and keyboard awareness
- Implement BannerManager singleton for banner lifecycle management
- Create floating banner screen with SafeAreaProvider integration
- Add comprehensive banner configuration types and positioning
- Update Banner component to use modern gesture handling
- Enhance BannerItem with improved typography and spacing (40px height)
- Add ConnectionBanner improvements with better sizing
- Remove ConnectionBanner from channel list (moved to floating system)
- Update screens constants (remove FLOATING_BANNER - handled as overlay)
- Add i18n support for limited network connection message

The system provides:
- Auto-hide functionality with customizable duration
- Position-aware rendering (top/bottom with keyboard adjustment)
- Tablet-specific offset handling
- Swipe-to-dismiss with configurable thresholds
- Custom component support alongside default banner items
- Comprehensive test coverage with device-specific scenarios

* docs: add floating banner system documentation and cleanup

- Add comprehensive floating-banner.md with architecture diagrams
- Remove incompatible connection_banner/index.ts file
- Update device.ts hooks for better keyboard handling
- Simplify screens/index.tsx floating banner registration
- Update test/setup.ts to remove deprecated keyboard mocks
- Clean up keyboard height logic and ESLint issues

The documentation covers:
- System architecture and component relationships
- API reference and usage patterns
- Performance considerations and best practices
- Integration points and troubleshooting guide
- Comprehensive testing strategy

All tests now pass with the updated setup.

* fix issue with translation file

* some self cleanup.

* renamed index.tsx => Banner.tsx

* creaete meaningful tests for Banner component and all the hooks.

* fix tests

* cleanup based on initial review by AI

* dismissible was set to true, changing to what was configured.

* making title and message optional

* addressed some comments in PR

* more fixes based on PR review.

* added future enhancement

* dismissOverlay will be awaited
* delay dismissing overlay so we don't have to show a new one all the time

* make the banner stackable

* Fix issue with last banner dismissal delayed by 2s

* update floating-banner test

* clean-up based on review by @enahum

* fix failing test

* fix failiing tests

* rename confusing var

* fixed issue with swipe not working on android

* fix issue w/ android not registering touch events behind the overlay

* fix failing test

* animate the banner moving up when bottom banner first appear.

* removed unused functions and update tests

* add useMemo and useCallback

* update jsdoc to say dismissable is default true

* fix failing test
2025-10-12 09:33:51 -06:00

302 lines
10 KiB
TypeScript

// 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');
});
});
});