mattermost-mobile/app/managers/banner_manager.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

263 lines
9.5 KiB
TypeScript

// 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,
};