mattermost-mobile-upstream/app/screens/navigation.ts
Pablo Vélez 4d842026f6
Some checks failed
ci / lint-typecheck (push) Has been cancelled
ci / detox-lint-typecheck (push) Has been cancelled
ci / test (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Mm 70564 channel labels use in channel creation (#10146)
* MM-70516 - Add ability to edit channel attribute values from Channel Info

* Add API methods for patching property fields and setting channel attribute requirements

* Update channel attribute clearing behavior for required and non-required fields

* MM-70564 - use channel attributes on channel creation

* Fix undefined propertyValues and ternary formatting in channel attributes

* Fix spelling of "colour" to "color" in attribute chip tests and related comments

* Add visibility checks for channel attribute labels in tests

* Fix data-integrity, permission, and accessibility issues in channel attributes: scoped pruning, atomic writes, race serialization, required clear/stale-option guards

* Fix handling of null values in apiGetChannelAttributeValue and update test assertions for channel attributes

* Improve channel attribute editor sizing for long labels, keep Save visible, and prevent keyboard overlap on Android

* Address CodeRabbit feedback for nullable values, bottom-sheet state, async validation, and reliable E2E cleanup

* lock-safe field fetch, create-time gate recheck, scoped value pruning, and tappable header chips

* Address CodeRabbit feedback: drop queued attribute saves on channel switch and use page object selectors in E2E

* Respect the ChannelAttributesRequired flag on channel creation and enable it for channel attribute E2E

* Skip required channel attribute E2E tests and stop enabling ChannelAttributesRequired on the shared server until  they move to SITE_3

* Fix clearing channel attributes on iOS by sending an empty value instead of null, and stop E2E suites from turning  off the ChannelAttributes flag mid-run

* Refactor getEmptyPropertyValue to accept string type for fieldType parameter

* E2E: give channel attribute test fields a suite-unique suffix

Pool shards may only create optional property fields named <name>_<6 hex>; fixed names were blocked by the pool guard.

* E2E: restore channel attribute cleanup checks; name network error codes

Brings back the PR's afterEach/afterAll error handling that the main merge replaced, and makes a no-response error show its codes (an AggregateError has an empty message).

* E2E: run channel attribute creation and editing specs on server B

Their 11 required-attribute tests could not run on the shared servers. Both specs now run on the mutation server with ChannelAttributesRequired on for their run, and the tests are re-enabled. setChannelAttributesRequired moves to the shared helper.

* Check header chips instead of their wrapper and use toExist for the create screen in channel attribute E2E

---------

Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-10-30 15:03:55 +01:00

206 lines
7.5 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {router} from 'expo-router';
import {DeviceEventEmitter} from 'react-native';
import {Events, Navigation, Screens} from '@constants';
import {UNAUTHENTICATED_SCREENS, HOME_TAB_SCREENS, SCREENS_AS_BOTTOM_SHEET, MODAL_SCREENS} from '@constants/screens';
import BottomSheetStore from '@store/bottom_sheet_store';
import {NavigationStore} from '@store/navigation_store';
import {logError} from '@utils/log';
import type {BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import type {AvailableScreens} from '@typings/screens/navigation';
// Expo Router URI-decodes params more than once between push and useLocalSearchParams, so any
// "%XX" in a value would be decoded. Escaping "%" as a JSON unicode escape leaves nothing to decode.
export function propsToParams(props: any): Record<string, string> {
return Object.keys(props || {}).reduce((params, key) => {
params[key] = JSON.stringify(props[key])?.replace(/%/g, '\\u0025');
return params;
}, {} as Record<string, string>);
}
export function updateParams(props: Record<string, any>) {
if (router) {
const params = propsToParams(props);
router.setParams(params);
}
}
// Helper to build Expo Router path from screen constant
export function getExpoRouterPath(screen: AvailableScreens, props?: any): string | undefined {
if (UNAUTHENTICATED_SCREENS.has(screen)) {
if (props?.isModal) {
return `/(modals)/(add-server)/${screen}`;
}
return `/(unauthenticated)/${screen}`;
}
if (HOME_TAB_SCREENS.has(screen)) {
return `/(authenticated)/(home)/${screen}`;
}
if (SCREENS_AS_BOTTOM_SHEET.has(screen)) {
return `/(bottom_sheet)/${screen}`;
}
if (MODAL_SCREENS.has(screen)) {
return `/(modals)/${screen}`;
}
return `/(authenticated)/${screen}`;
}
/**
* Navigation adapter that works with both RNN and Expo Router
* Tries Expo Router first, falls back to RNN if not available
*
* @param screen - Screen constant from @constants
* @param title - Screen title (used by RNN, ignored by Expo Router)
* @param params - Route parameters including theme
* @param options - RNN layout options (ignored by Expo Router as theme comes from params)
*/
export function navigateToScreen(screen: AvailableScreens, props?: Record<string, unknown>, reset = false) {
try {
if (router) {
const route = getExpoRouterPath(screen, props);
if (route) {
const params = propsToParams(props);
if (reset) {
router.replace({pathname: route, params});
} else {
router.push({pathname: route, params});
}
}
}
} catch (e) {
logError('navigateToScreen: Expo Router navigation failed', e);
}
}
export function navigateToScreenWithBaseRoute(baseRoute: string, screen: AvailableScreens, props?: Record<string, unknown>, reset = false) {
try {
const pathname = `${baseRoute}/${screen}`;
const params = propsToParams(props);
if (reset) {
router.replace({pathname, params});
} else {
router.push({pathname, params});
}
} catch (e) {
logError('navigateToScreenWithBaseRoute: Expo Router navigation failed', e);
}
}
export async function navigateBack() {
if (router && router.canGoBack()) {
router.back();
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
export function navigateToHomeTab(params?: Record<string, unknown>) {
DeviceEventEmitter.emit(Navigation.NAVIGATE_TO_TAB, {
screen: Screens.CHANNEL_LIST,
params,
});
}
export async function dismissToStackRoot() {
if (router && router.canDismiss()) {
router.dismissAll();
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
export function bottomSheet(
renderContent: () => React.ReactNode,
snapPoints: Array<string | number>,
footerComponent?: (props: BottomSheetFooterProps) => React.ReactNode,
keyboardBehavior?: 'extend' | 'fillParent' | 'interactive',
) {
DeviceEventEmitter.emit(Events.BLUR_AND_DISMISS_KEYBOARD);
BottomSheetStore.setSnapPoints(snapPoints);
BottomSheetStore.setRenderContentCallback(renderContent);
if (footerComponent) {
BottomSheetStore.setFooterComponent(footerComponent);
} else {
BottomSheetStore.removeFooterComponent();
}
if (keyboardBehavior) {
BottomSheetStore.setKeyboardBehavior(keyboardBehavior);
} else {
BottomSheetStore.removeKeyboardBehavior();
}
navigateToScreen(Screens.GENERIC_BOTTOM_SHEET);
}
export async function dismissBottomSheet() {
const hasRegularSheet = NavigationStore.isScreenInStack(Screens.BOTTOM_SHEET);
const hasGenericSheet = NavigationStore.isScreenInStack(Screens.GENERIC_BOTTOM_SHEET);
if (!hasRegularSheet && !hasGenericSheet) {
return;
}
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
// Prefer the regular BOTTOM_SHEET when present (historical contract); fall back to
// GENERIC_BOTTOM_SHEET so callers using the generic variant aren't silently ignored.
await NavigationStore.waitUntilScreensIsRemoved(hasRegularSheet ? Screens.BOTTOM_SHEET : Screens.GENERIC_BOTTOM_SHEET);
BottomSheetStore.reset();
await new Promise((resolve) => setTimeout(resolve, 250));
}
// Dismiss all screens (modals, bottom sheets, stack pushes) and return to channel_list.
// router.dismissTo traverses all navigator boundaries in expo-router 55, so a single call
// handles any stack shape: bottom sheet on top of modals on top of authenticated stack pushes.
export async function navigateToRoot() {
if (router) {
router.dismissTo(getExpoRouterPath(Screens.CHANNEL_LIST)!);
if (router.canDismiss()) {
router.dismissAll();
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
export async function dismissAllRoutesAndPopToScreen(screenId: AvailableScreens, passProps = {}) {
try {
if (!router) {
return;
}
const route = getExpoRouterPath(screenId);
if (!route) {
return;
}
if (NavigationStore.isScreenInStack(screenId)) {
// dismissTo only resolves divergence at the outermost navigator level
// it finds. With our nesting (root Stack -> (authenticated) Stack), one
// call only peels outer routes (modals, bottom sheets). A second call
// then operates on the inner stack and pops down to the target.
router.dismissTo(route);
router.dismissTo(route);
router.setParams(propsToParams(passProps));
await new Promise((resolve) => setTimeout(resolve, 250));
} else {
// Screen not in stack - reset to root then push target
await navigateToRoot();
navigateToScreen(screenId, passProps);
}
} catch (e) {
logError('dismissAllRoutesAndPopToScreen: Expo Router navigation failed', e);
}
}
export function navigateToSettingsScreen(screen: AvailableScreens, props?: Record<string, unknown>) {
navigateToScreenWithBaseRoute(`/(modals)/${Screens.SETTINGS}`, screen, props);
}
export function navigateToChannelInfoScreen(screen: AvailableScreens, props?: Record<string, unknown>) {
navigateToScreenWithBaseRoute(`/(modals)/${Screens.CHANNEL_INFO}`, screen, props);
}