authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-07-30 12:01:14-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 12:01:14-04:00
log323dc321a2f8bec2160e6e35f94d07c5bb949084
tree6a92017e2c68979f6862d385d09b048455fbd4c9
parent6154b46cb86b038f1b54c32690a794ac639520ca
signature Signed by PGP key B5690EEEBB952194

fix(mobile): support pre-Liquid-Glass iOS bottom toolbar (#4984)


6 files changed, 96 insertions(+), 61 deletions(-)

apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+11-3
......@@ -29,7 +29,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2929import { relativeTime } from "../../lib/time";
3030import { useThemeColor } from "../../lib/useThemeColor";
3131import { ThreadSwipeable } from "../home/thread-swipe-actions";
32import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
32import {
33 createNativeMailSearchToolbarItem,
34 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
35} from "../layout/native-mail-search-toolbar";
3336import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3437
3538export interface ArchivedThreadsHeaderEnvironment {
......@@ -70,7 +73,8 @@ function ArchivedThreadsHeader(props: {
7073 const searchIconColor = useThemeColor("--color-icon");
7174 const searchTextColor = useThemeColor("--color-foreground");
7275 const usesNativeChrome = Platform.OS === "ios";
73 const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
76 const usesCompactMailToolbar =
77 Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
7478 const androidFilterActions = useMemo<MenuAction[]>(
7579 () => [
7680 {
......@@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: {
272276 ...(usesNativeChrome
273277 ? {
274278 allowToolbarIntegration: true,
275 placement: "integratedButton" as const,
279 // "integratedButton" is an iOS 26 search-bar placement;
280 // pre-glass iOS keeps the default pull-down placement.
281 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
282 ? { placement: "integratedButton" as const }
283 : null),
276284 }
277285 : {
278286 placement: "stacked" as const,
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+6-2
......@@ -29,7 +29,10 @@ import {
2929 useAdaptiveWorkspacePaneRole,
3030 useRegisterWorkspaceInspector,
3131} from "../layout/AdaptiveWorkspaceLayout";
32import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
32import {
33 createNativeMailSearchToolbarItem,
34 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
35} from "../layout/native-mail-search-toolbar";
3336import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3437import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
3538import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
......@@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
354357 );
355358 }
356359
357 const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;
360 const usesCompactMailToolbar =
361 Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
358362
359363 return (
360364 <>
apps/mobile/src/features/home/HomeHeader.tsx+47-50
......@@ -13,7 +13,10 @@ import { useThemeColor } from "../../lib/useThemeColor";
1313import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
1414import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
1515import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
16import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
16import {
17 createNativeMailSearchToolbarItem,
18 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
19} from "../layout/native-mail-search-toolbar";
1720import type { HomeProjectSortOrder } from "./homeThreadList";
1821import {
1922 buildHomeListFilterMenu,
......@@ -320,9 +323,11 @@ function IosHomeHeader(props: HomeHeaderProps) {
320323 }),
321324 ]
322325 : undefined,
323 unstable_headerToolbarItems:
324 Platform.OS === "ios"
325 ? () => [
326 // The keys below are set per-branch (not `undefined`) so a later
327 // reapply cannot clobber options owned by NativeHeaderToolbar.
328 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
329 ? {
330 unstable_headerToolbarItems: () => [
326331 createNativeMailSearchToolbarItem({
327332 composeButtonId: "home-new-task",
328333 composeSystemImageName: "square.and.pencil",
......@@ -336,14 +341,14 @@ function IosHomeHeader(props: HomeHeaderProps) {
336341 placeholder: "Search",
337342 searchTextChangeId: "home-search-text",
338343 }),
339 ]
340 : undefined,
341 headerSearchBarOptions:
342 Platform.OS === "ios"
343 ? undefined
344 : {
344 ],
345 }
346 : {
347 // Pre-Liquid-Glass iOS: standard pull-down search in the nav
348 // bar; create + sort live in the plain bottom toolbar below.
349 headerSearchBarOptions: {
345350 ref: searchBarRef,
346 allowToolbarIntegration: true,
351 autoCapitalize: "none" as const,
347352 hideNavigationBar: false,
348353 placeholder: "Search",
349354 onCancelButtonPress: () => {
......@@ -353,21 +358,11 @@ function IosHomeHeader(props: HomeHeaderProps) {
353358 props.onSearchQueryChange(event.nativeEvent.text);
354359 },
355360 },
361 }),
356362 }}
357363 />
358364
359 {Platform.OS === "ios" ? null : (
360 <NativeHeaderToolbar placement="right">
361 <NativeHeaderToolbar.Button
362 accessibilityLabel="Open settings"
363 icon="gearshape"
364 onPress={props.onOpenSettings}
365 separateBackground
366 />
367 </NativeHeaderToolbar>
368 )}
369
370 {Platform.OS === "ios" ? null : (
365 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
371366 <NativeHeaderToolbar placement="bottom">
372367 <NativeHeaderToolbar.Menu
373368 accessibilityLabel="Filter and sort threads"
......@@ -421,35 +416,37 @@ function IosHomeHeader(props: HomeHeaderProps) {
421416 </NativeHeaderToolbar.Menu>
422417 ) : null}
423418
424 <NativeHeaderToolbar.Menu title="Sort projects">
425 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
426 {PROJECT_SORT_OPTIONS.map((option) => (
427 <NativeHeaderToolbar.MenuAction
428 key={option.value}
429 isOn={props.projectSortOrder === option.value}
430 onPress={() => props.onProjectSortOrderChange(option.value)}
431 >
432 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
433 </NativeHeaderToolbar.MenuAction>
434 ))}
435 </NativeHeaderToolbar.Menu>
419 {threadListV2Enabled ? null : (
420 <NativeHeaderToolbar.Menu title="Sort projects">
421 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
422 {PROJECT_SORT_OPTIONS.map((option) => (
423 <NativeHeaderToolbar.MenuAction
424 key={option.value}
425 isOn={props.projectSortOrder === option.value}
426 onPress={() => props.onProjectSortOrderChange(option.value)}
427 >
428 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
429 </NativeHeaderToolbar.MenuAction>
430 ))}
431 </NativeHeaderToolbar.Menu>
432 )}
436433
437 <NativeHeaderToolbar.Menu title="Sort threads">
438 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
439 {THREAD_SORT_OPTIONS.map((option) => (
440 <NativeHeaderToolbar.MenuAction
441 key={option.value}
442 isOn={props.threadSortOrder === option.value}
443 onPress={() => props.onThreadSortOrderChange(option.value)}
444 >
445 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
446 </NativeHeaderToolbar.MenuAction>
447 ))}
448 </NativeHeaderToolbar.Menu>
434 {threadListV2Enabled ? null : (
435 <NativeHeaderToolbar.Menu title="Sort threads">
436 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
437 {THREAD_SORT_OPTIONS.map((option) => (
438 <NativeHeaderToolbar.MenuAction
439 key={option.value}
440 isOn={props.threadSortOrder === option.value}
441 onPress={() => props.onThreadSortOrderChange(option.value)}
442 >
443 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
444 </NativeHeaderToolbar.MenuAction>
445 ))}
446 </NativeHeaderToolbar.Menu>
447 )}
449448 </NativeHeaderToolbar.Menu>
450 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
451 <NativeHeaderToolbar.SearchBarSlot />
452 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
449 <NativeHeaderToolbar.Spacer flexible />
453450 <NativeHeaderToolbar.Button
454451 accessibilityLabel="New task"
455452 icon="square.and.pencil"
apps/mobile/src/features/home/HomeScreen.tsx+13-5
......@@ -109,6 +109,7 @@ interface HomeScreenProps {
109109/* ─── Layout constants ───────────────────────────────────────────────── */
110110
111111const ESTIMATED_THREAD_ROW_HEIGHT = 72;
112const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
112113/**
113114 * Top spacing between the list and the Android custom header. The Android
114115 * header (AndroidHomeHeader) is rendered in-flow above this screen and
......@@ -196,6 +197,10 @@ export function HomeScreen(props: HomeScreenProps) {
196197 const listRef = useRef<LegendListRef | null>(null);
197198 const insets = useSafeAreaInsets();
198199 const accentColor = useThemeColor("--color-icon-muted");
200 const iosBottomToolbarClearance =
201 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
202 ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT
203 : 0;
199204 const searchEnvironmentIds = useMemo(
200205 () =>
201206 props.selectedEnvironmentId === null
......@@ -877,7 +882,7 @@ export function HomeScreen(props: HomeScreenProps) {
877882 <View
878883 className="flex-1 items-center justify-center bg-screen px-8"
879884 style={{
880 paddingBottom: Math.max(insets.bottom, 24),
885 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,
881886 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
882887 }}
883888 >
......@@ -1019,7 +1024,7 @@ export function HomeScreen(props: HomeScreenProps) {
10191024 contentContainerStyle={{
10201025 paddingBottom:
10211026 Platform.OS === "ios"
1022 ? Math.max(insets.bottom, 24) + 96
1027 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
10231028 : Math.max(insets.bottom, 16) + 88,
10241029 }}
10251030 />
......@@ -1060,16 +1065,19 @@ export function HomeScreen(props: HomeScreenProps) {
10601065 scrollEventThrottle={16}
10611066 contentContainerStyle={{
10621067 // Android reserves room for the floating new-task FAB
1063 // (56 button + 16 gap + bottom inset).
1068 // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a
1069 // standard 44pt bottom toolbar that overlays the list and is not
1070 // reflected in insets while contentInsetAdjustmentBehavior is
1071 // "never".
10641072 paddingBottom:
10651073 Platform.OS === "ios"
1066 ? Math.max(insets.bottom, 24) + 24
1074 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance
10671075 : Math.max(insets.bottom, 16) + 88,
10681076 }}
10691077 scrollIndicatorInsets={
10701078 Platform.OS === "ios"
10711079 ? {
1072 bottom: Math.max(insets.bottom, 16) + 24,
1080 bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance,
10731081 top: 0,
10741082 }
10751083 : undefined
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+11
......@@ -1,5 +1,16 @@
11import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
22
3import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
4
5/**
6 * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass
7 * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side
8 * silently drops the item and hides the navigation toolbar entirely. Screens
9 * that send it must fall back to standard search/toolbar primitives when this
10 * is false.
11 */
12export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;
13
314type NativeMailSearchToolbarInput = Omit<
415 HeaderBarButtonMailSearchToolbarItem,
516 "type" | "useFallbackSearchField"
apps/mobile/src/native/StackHeader.tsx+8-1
......@@ -340,7 +340,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
340340 return {
341341 type: "spacing",
342342 spacing: typeof child.props.width === "number" ? child.props.width : 8,
343 };
343 flexible: Boolean(child.props.flexible),
344 } as NativeStackHeaderItem;
344345 }
345346
346347 return null;
......@@ -351,6 +352,11 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
351352 Children.forEach(children, (child) => {
352353 const item = convertToolbarChild(child);
353354 if (item) {
355 if (item.type === "spacing") {
356 // Native inserts spacing items at `index`, treating a missing index
357 // as 0 — which would move the spacer in front of earlier siblings.
358 (item as { index?: number }).index = items.length;
359 }
354360 items.push(item);
355361 }
356362 });
......@@ -440,6 +446,7 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
440446NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
441447
442448function NativeHeaderToolbarSpacer(_props: {
449 readonly flexible?: boolean;
443450 readonly sharesBackground?: boolean;
444451 readonly width?: number;
445452}) {