import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; import { NavigationContext } from "@react-navigation/native"; import { use, useId, useLayoutEffect, type ReactElement } from "react"; import { Pressable, Text, useWindowDimensions, View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; import { NativeStackScreenOptions, useScreenOptionsWriter } from "../../native/StackHeader"; import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { isDuoDevice } from "../../lib/device-type"; import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout"; import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; import { InspectorCloseContext } from "../../features/layout/inspector-bar-store"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../../features/layout/native-mail-search-toolbar"; import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import type { AppBarMenu, AppBarMenuItem, AppBarProps } from "./AppBar.types"; import type { AppSymbolName } from "./AppSymbol"; import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; import { useControlTheme } from "./useControlTheme"; import { Menu } from "./Menu"; import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; function iosIcon(icon: AppSymbolName) { return typeof icon === "string" ? icon : icon.ios; } type MailMenu = NonNullable; function mailMenuItems(items: ReadonlyArray): MailMenu["items"] { return items.map((item) => "items" in item ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) } : { type: "action", title: item.title, state: item.selected ? "on" : "off", disabled: item.disabled, onPress: item.onPress, }, ); } const TITLE_BAR_MARGIN = 32; const TITLE_BAR_BUTTON = 44; /** Liquid-glass trailing clusters sit wider than the 44pt hit target; keep a clear edge. */ const TITLE_TRAILING_GAP = 24; /** Custom title that long-presses open a menu without UIKit's title-menu chevron. */ function AppBarTitleMenu(props: { readonly title: string; readonly subtitle?: string; readonly titleMenu: AppBarMenu; readonly tintColor: string; readonly mutedColor: string; readonly leadingButtons: number; readonly trailingButtons: number; }): ReactElement { const { width: windowWidth } = useWindowDimensions(); const columnWidth = useNativeColumnLayoutMetrics()?.width; const screenWidth = columnWidth ?? windowWidth; // Editor-style titleView sits after leading chrome (not bar-centered). Size to the remaining // gap so the label fills it and ellipsizes only against trailing actions. const leadingWidth = TITLE_BAR_MARGIN / 2 + props.leadingButtons * TITLE_BAR_BUTTON; const trailingWidth = TITLE_BAR_MARGIN / 2 + props.trailingButtons * TITLE_BAR_BUTTON + TITLE_TRAILING_GAP; const width = Math.max(TITLE_BAR_BUTTON, screenWidth - leadingWidth - trailingWidth); return ( { const action = findHeaderMenuAction(props.titleMenu.items, nativeEvent.event); if (action && !action.disabled) action.onPress(); }} style={{ width }} > {props.title} {props.subtitle ? ( {props.subtitle} ) : null} ); } export function AppBar(props: AppBarProps) { const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const deviceType = useDeviceType(); const headerId = useId(); const { layout } = useAdaptiveWorkspaceLayout(); const sidebarToggle = useSidebarToggle(); const closeInspector = use(InspectorCloseContext); const setOptions = useScreenOptionsWriter(); const ownsInspectorBar = closeInspector !== null && !props.embedded && !props.sheet; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- an inspector pane that stops drawing its bar must not leave it in the out-of-tree inspector bar useLayoutEffect(() => { if (!ownsInspectorBar || !setOptions) return; setOptions({ headerShown: true }); return () => setOptions({ headerShown: false }); }, [ownsInspectorBar, setOptions]); // The Duo's native columns show their own sidebar button while the sidebar is open. const showsSidebarToggle = sidebarToggle !== null && (!usesNativeWorkspaceColumns || deviceType === "pad" || !sidebarToggle.visible); const splitBackPress = props.backInSplitView?.onPress ?? props.onBack; const splitBack = layout.usesSplitView && props.backInSplitView && splitBackPress ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList : props.guardBack && props.onBack ? props.onBack : null; const compose = layout.usesSplitView && !(usesNativeWorkspaceColumns && isDuoDevice(deviceType)) ? props.onCompose : undefined; const { themeVariables } = useAppearancePreferences(); const { colors } = useControlTheme(); const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && usesNativeMailSearchToolbar; const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); // Where the mail toolbar isn't used, the Duo keeps search at the bottom of the pane like its // workspace lists; elsewhere inline search is a button in the bar. const duoBottomSearch = search !== undefined && !compactSearch && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && isDuoDevice(deviceType); const titleMenu = props.titleMenu; const titleLeadingButtons = Math.max( 1, (props.onClose || rootBack || splitBack || showsSidebarToggle ? 1 : 0) + (compose ? 1 : 0), ); const titleTrailingButtons = (props.actions?.length ?? 0) + visibleMenus.length + (props.trailing ? 1 : 0) + (props.confirm ? 1 : 0) + (closeInspector ? 1 : 0); if (props.embedded || props.sheet) return null; return ( <> ( ), // Keep the subtitle inside the width-capped title view; the native subtitle // ignores that cap and runs under trailing glass buttons. unstable_headerSubtitle: undefined, } : { headerTitle: undefined, unstable_headerSubtitle: props.subtitle || undefined }), ...(props.matchSearchSurface ? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } } : undefined), ...(search ? { unstable_headerToolbarItems: compactSearch ? () => [ createNativeMailSearchToolbarItem({ showsSearchDismissButton: true, placeholder: search.compactPlaceholder ?? search.placeholder, onSearchTextChange: search.onChangeText, searchTextChangeId: `${headerId}-search-text`, ...(menu ? { filterButtonId: `${headerId}-filter`, filterSystemImageName: iosIcon(menu.icon), filterMenu: { title: menu.title, items: mailMenuItems(menu.items) }, } : undefined), }), ] : () => [], headerSearchBarOptions: compactSearch ? undefined : { allowToolbarIntegration: true, ...(duoBottomSearch ? { placement: "integrated" as const } : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline" ? { placement: "integratedButton" as const } : undefined), autoCapitalize: "none", hideNavigationBar: false, ...(search.mode === "inline" || duoBottomSearch ? { obscureBackground: false } : undefined), placeholder: search.placeholder, onChangeText: (event) => search.onChangeText(event.nativeEvent.text), onCancelButtonPress: () => search.onChangeText(""), }, } : undefined), }} /> {props.onClose ? ( ) : rootBack || splitBack || showsSidebarToggle || compose ? ( {rootBack ? ( ) : null} {splitBack ? : null} {showsSidebarToggle ? ( ) : null} {compose ? ( ) : null} ) : null} {props.actions?.length || visibleMenus.length || props.trailing || props.confirm || closeInspector ? ( {props.actions?.map(({ loading, visibilityPriority, ...action }) => loading ? ( ) : ( ), )} {visibleMenus.map(createNativeHeaderMenu)} {props.trailing} {props.confirm?.loading ? ( ) : props.confirm ? ( ) : null} {closeInspector ? ( ) : null} ) : null} {duoBottomSearch ? ( ) : null} ); } export type { AppBarAction, AppBarConfirm, AppBarMenu, AppBarMenuItem, AppBarProps, AppBarSearch, } from "./AppBar.types";