// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable max-lines */ import RNUtils from '@mattermost/rnutils'; import merge from 'deepmerge'; import {Appearance, DeviceEventEmitter, Platform, Alert, type EmitterSubscription, Keyboard, StatusBar} from 'react-native'; import {type ComponentWillAppearEvent, type ImageResource, type LayoutOrientation, Navigation, type Options, OptionsModalPresentationStyle, type OptionsTopBarButton, type ScreenPoppedEvent, type EventSubscription} from 'react-native-navigation'; import tinyColor from 'tinycolor2'; import CompassIcon from '@components/compass_icon'; import {Events, Screens, Launch} from '@constants'; import {NOT_READY} from '@constants/screens'; import {getDefaultThemeByAppearance} from '@context/theme'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {isTablet} from '@utils/helpers'; import {logError} from '@utils/log'; import {appearanceControlledScreens, mergeNavigationOptions} from '@utils/navigation'; import {changeOpacity, setNavigatorStyles} from '@utils/theme'; import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet'; import type {default as UserProfileScreen} from '@screens/user_profile'; import type {LaunchProps} from '@typings/launch'; import type {AvailableScreens, NavButtons} from '@typings/screens/navigation'; import type {ComponentProps} from 'react'; import type {IntlShape} from 'react-intl'; import type {Asset} from 'react-native-image-picker'; const alpha = { from: 0, to: 1, duration: 150, }; let subscriptions: Array | undefined; export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait']; export const portraitOrientation: LayoutOrientation[] = ['portrait']; const loginFlowScreens = new Set([ Screens.ONBOARDING, Screens.SERVER, Screens.LOGIN, Screens.SSO, Screens.MFA, Screens.FORGOT_PASSWORD, ]); function setNavigationBarColor(screen: AvailableScreens, th?: Theme) { if (Platform.OS === 'android' && Platform.Version >= 34) { const theme = th || getThemeFromState(); const color = loginFlowScreens.has(screen) ? theme.sidebarBg : theme.centerChannelBg; RNUtils.setNavigationBarColor(color, tinyColor(color).isLight()); } } function showBottomTabsIfNeeded(screen: AvailableScreens) { if (screen === Screens.HOME) { DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); } } export function registerNavigationListeners() { subscriptions?.forEach((v) => v.remove()); subscriptions = [ Navigation.events().registerScreenPoppedListener(onPoppedListener), Navigation.events().registerCommandListener(onCommandListener), Navigation.events().registerComponentWillAppearListener(onScreenWillAppear), /** * For the time being and until we add the emoji picker in the keyboard area * will keep Android as adjustResize cause useAnimatedKeyboard from reanimated * is reporting the wrong values when the keyboard was opened but we switch * to a different channel or thread. */ // Navigation.events().registerComponentDidAppearListener(onScreenDidAppear), // Navigation.events().registerComponentDidDisappearListener(onScreenDidDisappear), ]; } function onCommandListener(name: string, params: any) { switch (name) { case 'setRoot': NavigationStore.clearScreensFromStack(); NavigationStore.addScreenToStack(params.layout.root.children[0].id); break; case 'push': NavigationStore.addScreenToStack(params.layout.id); break; case 'showModal': NavigationStore.addModalToStack(params.layout.children[0].id); break; case 'popToRoot': NavigationStore.clearScreensFromStack(); NavigationStore.addScreenToStack(Screens.HOME); break; case 'popTo': NavigationStore.popTo(params.componentId); break; case 'dismissModal': NavigationStore.removeModalFromStack(params.componentId); break; } const screen = NavigationStore.getVisibleScreen(); showBottomTabsIfNeeded(screen); setNavigationBarColor(screen); } function onPoppedListener({componentId}: ScreenPoppedEvent) { // screen pop does not trigger registerCommandListener, but does trigger screenPoppedListener const screen = componentId as AvailableScreens; NavigationStore.removeScreenFromStack(screen); // If we pop to home, we need to show the tab bar showBottomTabsIfNeeded(NavigationStore.getVisibleScreen()); setNavigationBarColor(screen); } function onScreenWillAppear(event: ComponentWillAppearEvent) { showBottomTabsIfNeeded(event.componentId as AvailableScreens); } export const loginAnimationOptions = () => { const theme = getThemeFromState(); return { layout: { backgroundColor: theme.centerChannelBg, componentBackgroundColor: theme.centerChannelBg, }, topBar: { visible: true, drawBehind: true, translucid: true, noBorder: true, elevation: 0, background: { color: 'transparent', }, backButton: { color: changeOpacity(theme.centerChannelColor, 0.56), }, scrollEdgeAppearance: { active: true, noBorder: true, translucid: true, }, }, animations: { topBar: { alpha, }, push: { waitForRender: false, content: { alpha, }, }, pop: { content: { alpha: { from: 1, to: 0, duration: 100, }, }, }, }, }; }; export const bottomSheetModalOptions = (theme: Theme, closeButtonId?: string): Options => { if (closeButtonId) { const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor); const closeButtonTestId = `${closeButtonId.replace('close-', 'close.').replace(/-/g, '_')}.button`; return { modalPresentationStyle: OptionsModalPresentationStyle.formSheet, topBar: { leftButtons: [{ id: closeButtonId, icon: closeButton, testID: closeButtonTestId, }], leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56), background: { color: theme.centerChannelBg, }, title: { color: theme.centerChannelColor, }, }, }; } return { animations: { showModal: { enabled: false, }, dismissModal: { enabled: false, }, }, modalPresentationStyle: Platform.select({ ios: OptionsModalPresentationStyle.overFullScreen, default: OptionsModalPresentationStyle.overCurrentContext, }), statusBar: { backgroundColor: theme.sidebarBg, drawBehind: true, translucent: true, }, }; }; // This locks phones to portrait for all screens while keeps // all orientations available for Tablets. Navigation.setDefaultOptions({ animations: { setRoot: { enter: { waitForRender: true, enabled: true, alpha: { from: 0, to: 1, duration: 300, }, }, }, }, layout: { orientation: isTablet() ? allOrientations : portraitOrientation, }, topBar: { title: { fontFamily: 'Metropolis-SemiBold', fontSize: 18, fontWeight: '600', }, backButton: { enableMenu: false, }, subtitle: { fontFamily: 'OpenSans', fontSize: 12, fontWeight: '400', }, }, }); Appearance.addChangeListener(() => { const theme = getThemeFromState(); const screens = NavigationStore.getScreensInStack(); if (screens.includes(Screens.SERVER) || screens.includes(Screens.ONBOARDING)) { for (const screen of screens) { if (appearanceControlledScreens.has(screen)) { Navigation.updateProps(screen, {theme}); setNavigatorStyles(screen, theme, loginAnimationOptions(), theme.sidebarBg); } } } }); export function setScreensOrientation(allowRotation: boolean) { const options: Options = { layout: { orientation: allowRotation ? allOrientations : portraitOrientation, }, }; Navigation.setDefaultOptions(options); const screens = NavigationStore.getScreensInStack(); for (const s of screens) { Navigation.mergeOptions(s, options); } } export function getThemeFromState(): Theme { return EphemeralStore.theme || getDefaultThemeByAppearance(); } // This is a temporary helper function to avoid // crashes when trying to load a screen that does // NOT exists, this should be removed for GA function isScreenRegistered(screen: AvailableScreens) { const notImplemented = NOT_READY.includes(screen) || !Object.values(Screens).includes(screen); if (notImplemented) { Alert.alert( 'Temporary error ' + screen, 'The functionality you are trying to use has not been implemented yet', ); return false; } return true; } function edgeToEdgeHack(screen: AvailableScreens, theme: Theme) { const isDark = tinyColor(theme.sidebarBg).isDark(); if (Platform.OS === 'android') { if (Platform.Version >= 34) { const listener = Navigation.events().registerComponentDidAppearListener((event) => { if (event.componentName === screen) { setNavigationBarColor(screen, theme); listener.remove(); } }); } if (Platform.Version >= 36) { return { drawBehind: true, translucent: false, isDark, }; } } StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content'); return {isDark}; } export function openToS() { NavigationStore.setToSOpen(true); return showOverlay(Screens.TERMS_OF_SERVICE, {}, {overlay: {interceptTouchOutside: true}}); } export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}) { const theme = getThemeFromState(); const edgeToEdge = edgeToEdgeHack(Screens.HOME, theme); if (!passProps.coldStart && (passProps.launchType === Launch.AddServer || passProps.launchType === Launch.AddServerFromDeepLink)) { dismissModal({componentId: Screens.SERVER}); dismissModal({componentId: Screens.LOGIN}); dismissModal({componentId: Screens.SSO}); dismissModal({componentId: Screens.BOTTOM_SHEET}); if (passProps.launchType === Launch.AddServerFromDeepLink) { Navigation.updateProps(Screens.HOME, {launchType: Launch.DeepLink, extra: passProps.extra}); } return ''; } const stack = { children: [{ component: { id: Screens.HOME, name: Screens.HOME, passProps, options: { layout: { componentBackgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, backgroundColor: theme.sidebarBg, ...edgeToEdge, }, topBar: { visible: false, height: 0, background: { color: theme.sidebarBg, }, backButton: { visible: false, color: theme.sidebarHeaderTextColor, }, }, }, }, }], }; return Navigation.setRoot({ root: {stack}, }); } export function resetToSelectServer(passProps: LaunchProps) { const theme = getDefaultThemeByAppearance(); const edgeToEdge = edgeToEdgeHack(Screens.SERVER, theme); const children = [{ component: { id: Screens.SERVER, name: Screens.SERVER, passProps: { ...passProps, theme, }, options: { layout: { backgroundColor: theme.centerChannelBg, componentBackgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, backgroundColor: theme.sidebarBg, ...edgeToEdge, }, topBar: { backButton: { color: theme.sidebarHeaderTextColor, title: '', }, background: { color: theme.sidebarBg, }, visible: false, height: 0, }, }, }, }]; return Navigation.setRoot({ root: { stack: { children, }, }, }); } export function resetToOnboarding(passProps: LaunchProps) { const theme = getDefaultThemeByAppearance(); const edgeToEdge = edgeToEdgeHack(Screens.ONBOARDING, theme); const children = [{ component: { id: Screens.ONBOARDING, name: Screens.ONBOARDING, passProps: { ...passProps, theme, }, options: { layout: { backgroundColor: theme.centerChannelBg, componentBackgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, backgroundColor: theme.sidebarBg, ...edgeToEdge, }, topBar: { backButton: { color: theme.sidebarHeaderTextColor, title: '', }, background: { color: theme.sidebarBg, }, visible: false, height: 0, }, }, }, }]; return Navigation.setRoot({ root: { stack: { children, }, }, }); } export function resetToTeams() { const theme = getThemeFromState(); const edgeToEdge = edgeToEdgeHack(Screens.SELECT_TEAM, theme); return Navigation.setRoot({ root: { stack: { children: [{ component: { id: Screens.SELECT_TEAM, name: Screens.SELECT_TEAM, options: { layout: { componentBackgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, backgroundColor: theme.sidebarBg, ...edgeToEdge, }, topBar: { visible: false, height: 0, background: { color: theme.sidebarBg, }, backButton: { visible: false, color: theme.sidebarHeaderTextColor, }, }, }, }, }], }, }, }); } export function goToScreen(name: AvailableScreens, title: string, passProps = {}, options: Options = {}) { if (!isScreenRegistered(name)) { return ''; } const theme = getThemeFromState(); const edgeToEdge = edgeToEdgeHack(name, theme); const componentId = NavigationStore.getVisibleScreen(); if (!componentId) { logError('Trying to go to screen without any screen on the navigation store'); return ''; } const defaultOptions: Options = { layout: { componentBackgroundColor: theme.centerChannelBg, }, popGesture: true, sideMenu: { left: {enabled: false}, right: {enabled: false}, }, statusBar: { style: edgeToEdge.isDark ? 'light' : 'dark', backgroundColor: theme.sidebarBg, drawBehind: edgeToEdge.drawBehind ?? false, translucent: edgeToEdge.translucent ?? false, }, topBar: { animate: true, visible: true, backButton: { color: theme.sidebarHeaderTextColor, title: '', testID: 'screen.back.button', }, background: { color: theme.sidebarBg, }, title: { color: theme.sidebarHeaderTextColor, text: title, }, }, }; DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, false); if (NavigationStore.getScreensInStack().includes(name)) { Navigation.updateProps(name, passProps); return Navigation.popTo(name, merge(defaultOptions, options)); } return Navigation.push(componentId, { component: { id: name, name, passProps, options: merge(defaultOptions, options), }, }); } export async function popTopScreen(screenId?: AvailableScreens) { try { if (screenId) { await Navigation.pop(screenId); } else { const componentId = NavigationStore.getVisibleScreen(); await Navigation.pop(componentId); } } catch (error) { // RNN returns a promise rejection if there are no screens // atop the root screen to pop. We'll do nothing in this case. } } export async function popTo(screenId: AvailableScreens) { try { await Navigation.popTo(screenId); } catch (error) { // RNN returns a promise rejection if there are no screens // atop the root screen to pop. We'll do nothing in this case. } } export async function popToRoot() { const componentId = NavigationStore.getVisibleScreen(); try { await Navigation.popToRoot(componentId); } catch (error) { // RNN returns a promise rejection if there are no screens // atop the root screen to pop. We'll do nothing in this case. } } export async function dismissAllModalsAndPopToRoot() { await dismissAllModals(); await dismissAllOverlays(); await popToRoot(); } /** * Dismisses All modals in the stack and pops the stack to the desired screen * (if the screen is not in the stack, it will push a new one) * @param screenId Screen to pop or display * @param title Title to be shown in the top bar * @param passProps Props to pass to the screen * @param options Navigation options */ export async function dismissAllModalsAndPopToScreen(screenId: AvailableScreens, title: string, passProps = {}, options = {}) { await dismissAllModals(); await dismissAllOverlays(); if (NavigationStore.getScreensInStack().includes(screenId)) { let mergeOptions = options; if (title) { mergeOptions = merge(mergeOptions, { topBar: { title: { text: title, }, }, }); } try { await Navigation.popTo(screenId, mergeOptions); if (Object.keys(passProps).length > 0) { Navigation.updateProps(screenId, passProps); } } catch { // catch in case there is nothing to pop } } else { await goToScreen(screenId, title, passProps, options); } } export function showModal(name: AvailableScreens, title: string, passProps = {}, options: Options = {}) { if (!isScreenRegistered(name) || NavigationStore.getVisibleModal() === name) { return; } const theme = getThemeFromState(); const edgeToEdge = edgeToEdgeHack(name, theme); const modalPresentationStyle: OptionsModalPresentationStyle = Platform.OS === 'ios' ? OptionsModalPresentationStyle.pageSheet : OptionsModalPresentationStyle.none; const defaultOptions: Options = { modalPresentationStyle, layout: { componentBackgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, backgroundColor: theme.sidebarBg, ...edgeToEdge, }, topBar: { animate: true, visible: true, backButton: { color: theme.sidebarHeaderTextColor, title: '', }, background: { color: theme.sidebarBg, }, title: { color: theme.sidebarHeaderTextColor, text: title, }, leftButtonColor: theme.sidebarHeaderTextColor, rightButtonColor: theme.sidebarHeaderTextColor, }, modal: {swipeToDismiss: false}, }; Navigation.showModal({ stack: { children: [{ component: { id: name, name, passProps: { ...passProps, isModal: true, }, options: merge(defaultOptions, options), }, }], }, }); } export function showModalOverCurrentContext(name: AvailableScreens, passProps = {}, options: Options = {}) { const title = ''; let animations; switch (Platform.OS) { case 'android': animations = { showModal: { waitForRender: false, alpha: { from: 0, to: 1, duration: 250, }, }, dismissModal: { alpha: { from: 1, to: 0, duration: 250, }, }, }; break; default: animations = { showModal: { alpha: { from: 0, to: 1, duration: 250, }, }, dismissModal: { enter: { enabled: false, }, exit: { enabled: false, }, }, }; break; } const defaultOptions = { modalPresentationStyle: OptionsModalPresentationStyle.overCurrentContext, layout: { backgroundColor: 'transparent', componentBackgroundColor: 'transparent', }, topBar: { visible: false, height: 0, }, animations, }; const mergeOptions = merge(defaultOptions, options); showModal(name, title, passProps, mergeOptions); } export async function dismissModal(options?: Options & { componentId: AvailableScreens}) { if (!NavigationStore.hasModalsOpened()) { return; } const componentId = options?.componentId || NavigationStore.getVisibleModal(); if (componentId) { try { await Navigation.dismissModal(componentId, options); } catch (error) { // RNN returns a promise rejection if there is no modal to // dismiss. We'll do nothing in this case. } } } export async function dismissAllModals() { if (!NavigationStore.hasModalsOpened()) { return; } try { const modals = [...NavigationStore.getModalsInStack()]; for await (const modal of modals) { await Navigation.dismissModal(modal, {animations: {dismissModal: {enabled: false}}}); } } catch (error) { // RNN returns a promise rejection if there are no modals to // dismiss. We'll do nothing in this case. } } export const buildNavigationButton = (id: string, testID: string, icon?: ImageResource, text?: string): OptionsTopBarButton => ({ fontSize: 16, fontFamily: 'OpenSans-SemiBold', fontWeight: '600', id, icon, showAsAction: 'always', testID, text, }); export function setButtons(componentId: AvailableScreens, buttons: NavButtons = {leftButtons: [], rightButtons: []}) { const options = { topBar: { ...buttons, }, }; mergeNavigationOptions(componentId, options); } export function showOverlay(name: AvailableScreens, passProps = {}, options: Options = {}, id?: string) { if (!isScreenRegistered(name)) { return; } const defaultOptions = { layout: { backgroundColor: 'transparent', componentBackgroundColor: 'transparent', }, overlay: { interceptTouchOutside: false, }, }; Navigation.showOverlay({ component: { id, name, passProps, options: merge(defaultOptions, options), }, }); } export async function dismissOverlay(componentId: string) { try { await Navigation.dismissOverlay(componentId); } catch (error) { // RNN returns a promise rejection if there is no modal with // this componentId to dismiss. We'll do nothing in this case. } } export async function dismissAllOverlays() { try { await Navigation.dismissAllOverlays(); } catch { // do nothing } } type BottomSheetArgs = { closeButtonId: string; enableDynamicSizing?: boolean; initialSnapIndex?: number; footerComponent?: React.FC; renderContent: () => React.ReactNode; snapPoints: Array; theme: Theme; title: string; scrollable?: boolean; } export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false, enableDynamicSizing}: BottomSheetArgs) { if (isTablet()) { showModal(Screens.BOTTOM_SHEET, title, { closeButtonId, enableDynamicSizing, initialSnapIndex, renderContent, footerComponent, snapPoints, scrollable, }, bottomSheetModalOptions(theme, closeButtonId)); } else { showModalOverCurrentContext(Screens.BOTTOM_SHEET, { enableDynamicSizing, initialSnapIndex, renderContent, footerComponent, snapPoints, scrollable, }, bottomSheetModalOptions(theme)); } } export async function dismissBottomSheet(alternativeScreen: AvailableScreens = Screens.BOTTOM_SHEET) { DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET); await NavigationStore.waitUntilScreensIsRemoved(alternativeScreen); } type AsBottomSheetArgs = { closeButtonId: string; props?: Record; screen: AvailableScreens; theme: Theme; title: string; } export function openAsBottomSheet({closeButtonId, screen, theme, title, props}: AsBottomSheetArgs) { if (isTablet()) { showModal(screen, title, { closeButtonId, ...props, }, bottomSheetModalOptions(theme, closeButtonId)); } else { showModalOverCurrentContext(screen, props, bottomSheetModalOptions(theme)); } } export function openAttachmentOptions( intl: IntlShape, theme: Theme, props: { onUploadFiles: (files: Asset[]) => void; maxFilesReached: boolean; canUploadFiles: boolean; testID?: string; fileCount?: number; maxFileCount?: number; }, ) { const title = intl.formatMessage({id: 'mobile.file_attachment.title', defaultMessage: 'Files and media'}); openAsBottomSheet({ closeButtonId: 'attachment-close-id', screen: Screens.ATTACHMENT_OPTIONS, theme, title, props, }); } export const showAppForm = async (form: AppForm, context: AppContext) => { const passProps = {form, context}; showModal(Screens.APPS_FORM, form.title || '', passProps); }; export const showReviewOverlay = (hasAskedBefore: boolean) => { showOverlay( Screens.REVIEW_APP, {hasAskedBefore}, {overlay: {interceptTouchOutside: true}}, ); }; export const showShareFeedbackOverlay = () => { showOverlay( Screens.SHARE_FEEDBACK, {}, {overlay: {interceptTouchOutside: true}}, ); }; export async function findChannels(title: string, theme: Theme) { const options: Options = {}; const closeButtonId = 'close-find-channels'; const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); options.topBar = { leftButtons: [{ id: closeButtonId, icon: closeButton, testID: 'close.find_channels.button', }], }; showModal( Screens.FIND_CHANNELS, title, {closeButtonId}, options, ); } export async function openUserProfileModal( intl: IntlShape, theme: Theme, props: Omit, 'closeButtonId'>, screenToDismiss?: AvailableScreens, ) { if (screenToDismiss) { await dismissBottomSheet(screenToDismiss); } const screen = Screens.USER_PROFILE; const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); const closeButtonId = 'close-user-profile'; Keyboard.dismiss(); openAsBottomSheet({screen, title, theme, closeButtonId, props: {...props}}); }