mattermost-mobile/app/screens/navigation.ts
Nick Misasi 12c8d80f4a
[MM-66591] Channel Summaries with Agents (#9405)
* Add support for Channel Summarization, inline citations

* Add support for Channel Summarization, inline citations

* Revert package-lock to master

* i18n fixes

* e2e fixes

* Remove a few tests as they're already covered by unit

* Ensure channel summary is offline compatible

- Add channel persistence check before switching to summary channel
- Fetch and persist channel/membership if not in database
- Fix TypeScript error handling for unknown error types
- Update tests to properly mock DatabaseManager

* Fixes

* Fix expo-image patch file - remove accidental build artifacts

The patch file was accidentally corrupted with build artifacts from
node_modules/expo-image/android/build/ including .dex files, results.bin,
and Oops.rej. This was causing the 16KB page size compatibility patch
to fail during CI.

* Fix iOS date picker spacing and placeholder text

- Increase snap points on iOS to accommodate inline date picker spinner
- Update AI prompt placeholder to "Ask AI about this channel"

* Fix padding

* Address PR review feedback from larkox

- Fix i18n: use defineMessages pattern instead of separate labelId/defaultLabel
- Replace custom SummaryOptionItem with existing OptionItem component
- Add ScrollView for scrollability on smaller phones
- Extract AgentItem to separate file, use FlatList for virtualization
- Extract DateInputField component, unify DateTimePicker, add useCallback
- Consolidate types into app/products/agents/types/api.ts
- Remove unnecessary fetchPostById (WebSocket handles post delivery)
- Use jest.mocked() instead of 'as jest.Mock' for type safety
- Improve transform.ts: use urlParse, add post ID length constraint,
  validate internal links with serverUrl parameter, add subpath support
- Add transform.test.ts cases for external links, subpaths, URL encoding
- Remove unnecessary Platform.select in inline_entity_link
- Revert video_file.tsx and prefetch.ts to use cachePath (patches include types)

* Update en.json with new channel summary option translations

Added missing translation keys for the defineMessages pattern used
in channel summary sheet options.

* Fix regex patterns and add negative assertions per PR review

- Use exact 26-character constraint in post ID regex instead of
  permissive pattern followed by validation
- Use specific identifier pattern in channel regex instead of [^/]+
- Remove unused ID_PATH_PATTERN and IDENTIFIER_PATH_PATTERN imports
- Add negative assertions to processInlineEntities tests to verify
  mutual exclusivity of link vs inline_entity_link nodes

* Add test cases for non-internal URLs and escaped slashes

Per PR review feedback, added tests for:
- Non-internal URLs with similar shape to internal links
- URLs with escaped slashes (%2F) in various positions

* Improve UI consistency for channel summary date picker and AI input

- Add focus state (border highlight) to AI prompt input on Ask Agents sheet
- Add active state highlight to Start/End date fields in date range picker
- Increase back button tap area on date range picker using hitSlop
- Fix date picker theme mismatch by setting themeVariant based on app theme
- Apply initial date when opening picker so value matches displayed date

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate AskAgentsOption behind agents plugin availability check

Add a plugin availability check for the "Ask Agents" feature that
mirrors the webapp's useGetAgentsBridgeEnabled hook. Calls
GET /api/v4/agents/status on WebSocket connect and plugin status
change events, stores the result in an RxJS BehaviorSubject
ephemeral store, and conditionally renders the AskAgentsOption
component based on the plugin being available.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix quick actions tests and default date range to last 7 days

Mock useAgentsConfig in quick actions tests so the Ask Agents button
renders when agents plugin availability is gated. Default the date
range picker to the last 7 days instead of empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address enahum's review feedback on channel summaries PR

- Remove pluginEnabled=false on network error in agents_status
- Use enableDynamicSizing for channel summary bottom sheet
- Extract channel navigation logic to switchToChannelByName action
- Move icon color to stylesheet, null guard to parent in InlineEntityLink
- Replace custom URL parsing with parseDeepLink in transform.ts
- Reorder channel membership check before API request in channel_summary
- Use FormattedText, FormattedDate, FloatingTextInput consistently
- Use getErrorMessage for proper intl error handling in alerts
- Simplify agent_item to always use expo-image, memoize styles
- Gate Ask Agents item count on agentsEnabled in header.tsx
- Clean up CLAUDE-IOS-SIMULATOR.md per review suggestions
- Update tests to match new behavior

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix i18n extraction for date_range_picker translated strings

Add defineMessages declarations so the i18n extraction tool can find
the translated string IDs passed through custom props to DateInputField.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix agent profile image not rendering on agent selector

* Reduce channel summary bottom sheet max height from 80% to 50%

The 80% snap point created too much whitespace below the options.
50% fits the main content well while still leaving room for the
iOS date picker spinner when the custom date range view is shown.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Adjust channel summary bottom sheet max height to 65%

50% was too short, cutting off the iOS date picker spinner.
65% provides enough room for the date range view with spinner
while keeping the main options view compact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use platform-specific max height for channel summary bottom sheet

iOS uses 65% to accommodate the inline date picker spinner.
Android uses 50% since its modal date picker doesn't need extra space.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reduce height on android slightly

* Final tweaks for spacing

* Address PR review feedback from larkox and pvev

- Use MessageDescriptor props instead of separate id/defaultMessage strings in DateInputField
- Extract hitSlop constant outside component for render stability
- Normalize date range to UTC start/end of day to avoid timezone data loss
- Replace hardcoded '#FFFFFF' with theme.buttonColor

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 07:00:43 -05:00

998 lines
30 KiB
TypeScript

// 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<EmitterSubscription | EventSubscription> | undefined;
export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait'];
export const portraitOrientation: LayoutOrientation[] = ['portrait'];
const loginFlowScreens = new Set<AvailableScreens>([
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<BottomSheetFooterProps>;
renderContent: () => React.ReactNode;
snapPoints: Array<number | string>;
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<string, any>;
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<ComponentProps<typeof UserProfileScreen>, '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}});
}