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";...@@ -29,7 +29,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
29import { relativeTime } from "../../lib/time";29import { relativeTime } from "../../lib/time";
30import { useThemeColor } from "../../lib/useThemeColor";30import { useThemeColor } from "../../lib/useThemeColor";
31import { ThreadSwipeable } from "../home/thread-swipe-actions";31import { 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";
33import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";36import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3437
35export interface ArchivedThreadsHeaderEnvironment {38export interface ArchivedThreadsHeaderEnvironment {
...@@ -70,7 +73,8 @@ function ArchivedThreadsHeader(props: {...@@ -70,7 +73,8 @@ function ArchivedThreadsHeader(props: {
70 const searchIconColor = useThemeColor("--color-icon");73 const searchIconColor = useThemeColor("--color-icon");
71 const searchTextColor = useThemeColor("--color-foreground");74 const searchTextColor = useThemeColor("--color-foreground");
72 const usesNativeChrome = Platform.OS === "ios";75 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;
74 const androidFilterActions = useMemo<MenuAction[]>(78 const androidFilterActions = useMemo<MenuAction[]>(
75 () => [79 () => [
76 {80 {
...@@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: {...@@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: {
272 ...(usesNativeChrome276 ...(usesNativeChrome
273 ? {277 ? {
274 allowToolbarIntegration: true,278 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),
276 }284 }
277 : {285 : {
278 placement: "stacked" as const,286 placement: "stacked" as const,
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+6-2
...@@ -29,7 +29,10 @@ import {...@@ -29,7 +29,10 @@ import {
29 useAdaptiveWorkspacePaneRole,29 useAdaptiveWorkspacePaneRole,
30 useRegisterWorkspaceInspector,30 useRegisterWorkspaceInspector,
31} from "../layout/AdaptiveWorkspaceLayout";31} 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";
33import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";36import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
34import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";37import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
35import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";38import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
...@@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
354 );357 );
355 }358 }
356359
357 const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;360 const usesCompactMailToolbar =
361 Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
358362
359 return (363 return (
360 <>364 <>
apps/mobile/src/features/home/HomeHeader.tsx+47-50
...@@ -13,7 +13,10 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -13,7 +13,10 @@ import { useThemeColor } from "../../lib/useThemeColor";
13import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";13import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
14import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";14import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
15import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";15import { 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";
17import type { HomeProjectSortOrder } from "./homeThreadList";20import type { HomeProjectSortOrder } from "./homeThreadList";
18import {21import {
19 buildHomeListFilterMenu,22 buildHomeListFilterMenu,
...@@ -320,9 +323,11 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -320,9 +323,11 @@ function IosHomeHeader(props: HomeHeaderProps) {
320 }),323 }),
321 ]324 ]
322 : undefined,325 : undefined,
323 unstable_headerToolbarItems:326 // The keys below are set per-branch (not `undefined`) so a later
324 Platform.OS === "ios"327 // reapply cannot clobber options owned by NativeHeaderToolbar.
325 ? () => [328 ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
329 ? {
330 unstable_headerToolbarItems: () => [
326 createNativeMailSearchToolbarItem({331 createNativeMailSearchToolbarItem({
327 composeButtonId: "home-new-task",332 composeButtonId: "home-new-task",
328 composeSystemImageName: "square.and.pencil",333 composeSystemImageName: "square.and.pencil",
...@@ -336,14 +341,14 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -336,14 +341,14 @@ function IosHomeHeader(props: HomeHeaderProps) {
336 placeholder: "Search",341 placeholder: "Search",
337 searchTextChangeId: "home-search-text",342 searchTextChangeId: "home-search-text",
338 }),343 }),
339 ]344 ],
340 : undefined,345 }
341 headerSearchBarOptions:346 : {
342 Platform.OS === "ios"347 // Pre-Liquid-Glass iOS: standard pull-down search in the nav
343 ? undefined348 // bar; create + sort live in the plain bottom toolbar below.
344 : {349 headerSearchBarOptions: {
345 ref: searchBarRef,350 ref: searchBarRef,
346 allowToolbarIntegration: true,351 autoCapitalize: "none" as const,
347 hideNavigationBar: false,352 hideNavigationBar: false,
348 placeholder: "Search",353 placeholder: "Search",
349 onCancelButtonPress: () => {354 onCancelButtonPress: () => {
...@@ -353,21 +358,11 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -353,21 +358,11 @@ function IosHomeHeader(props: HomeHeaderProps) {
353 props.onSearchQueryChange(event.nativeEvent.text);358 props.onSearchQueryChange(event.nativeEvent.text);
354 },359 },
355 },360 },
361 }),
356 }}362 }}
357 />363 />
358364
359 {Platform.OS === "ios" ? null : (365 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? 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 : (
371 <NativeHeaderToolbar placement="bottom">366 <NativeHeaderToolbar placement="bottom">
372 <NativeHeaderToolbar.Menu367 <NativeHeaderToolbar.Menu
373 accessibilityLabel="Filter and sort threads"368 accessibilityLabel="Filter and sort threads"
...@@ -421,35 +416,37 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -421,35 +416,37 @@ function IosHomeHeader(props: HomeHeaderProps) {
421 </NativeHeaderToolbar.Menu>416 </NativeHeaderToolbar.Menu>
422 ) : null}417 ) : null}
423418
424 <NativeHeaderToolbar.Menu title="Sort projects">419 {threadListV2Enabled ? null : (
425 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>420 <NativeHeaderToolbar.Menu title="Sort projects">
426 {PROJECT_SORT_OPTIONS.map((option) => (421 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
427 <NativeHeaderToolbar.MenuAction422 {PROJECT_SORT_OPTIONS.map((option) => (
428 key={option.value}423 <NativeHeaderToolbar.MenuAction
429 isOn={props.projectSortOrder === option.value}424 key={option.value}
430 onPress={() => props.onProjectSortOrderChange(option.value)}425 isOn={props.projectSortOrder === option.value}
431 >426 onPress={() => props.onProjectSortOrderChange(option.value)}
432 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>427 >
433 </NativeHeaderToolbar.MenuAction>428 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
434 ))}429 </NativeHeaderToolbar.MenuAction>
435 </NativeHeaderToolbar.Menu>430 ))}
431 </NativeHeaderToolbar.Menu>
432 )}
436433
437 <NativeHeaderToolbar.Menu title="Sort threads">434 {threadListV2Enabled ? null : (
438 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>435 <NativeHeaderToolbar.Menu title="Sort threads">
439 {THREAD_SORT_OPTIONS.map((option) => (436 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
440 <NativeHeaderToolbar.MenuAction437 {THREAD_SORT_OPTIONS.map((option) => (
441 key={option.value}438 <NativeHeaderToolbar.MenuAction
442 isOn={props.threadSortOrder === option.value}439 key={option.value}
443 onPress={() => props.onThreadSortOrderChange(option.value)}440 isOn={props.threadSortOrder === option.value}
444 >441 onPress={() => props.onThreadSortOrderChange(option.value)}
445 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>442 >
446 </NativeHeaderToolbar.MenuAction>443 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
447 ))}444 </NativeHeaderToolbar.MenuAction>
448 </NativeHeaderToolbar.Menu>445 ))}
446 </NativeHeaderToolbar.Menu>
447 )}
449 </NativeHeaderToolbar.Menu>448 </NativeHeaderToolbar.Menu>
450 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />449 <NativeHeaderToolbar.Spacer flexible />
451 <NativeHeaderToolbar.SearchBarSlot />
452 <NativeHeaderToolbar.Spacer width={8} sharesBackground={false} />
453 <NativeHeaderToolbar.Button450 <NativeHeaderToolbar.Button
454 accessibilityLabel="New task"451 accessibilityLabel="New task"
455 icon="square.and.pencil"452 icon="square.and.pencil"
apps/mobile/src/features/home/HomeScreen.tsx+13-5
...@@ -109,6 +109,7 @@ interface HomeScreenProps {...@@ -109,6 +109,7 @@ interface HomeScreenProps {
109/* ─── Layout constants ───────────────────────────────────────────────── */109/* ─── Layout constants ───────────────────────────────────────────────── */
110110
111const ESTIMATED_THREAD_ROW_HEIGHT = 72;111const ESTIMATED_THREAD_ROW_HEIGHT = 72;
112const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
112/**113/**
113 * Top spacing between the list and the Android custom header. The Android114 * Top spacing between the list and the Android custom header. The Android
114 * header (AndroidHomeHeader) is rendered in-flow above this screen and115 * header (AndroidHomeHeader) is rendered in-flow above this screen and
...@@ -196,6 +197,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -196,6 +197,10 @@ export function HomeScreen(props: HomeScreenProps) {
196 const listRef = useRef<LegendListRef | null>(null);197 const listRef = useRef<LegendListRef | null>(null);
197 const insets = useSafeAreaInsets();198 const insets = useSafeAreaInsets();
198 const accentColor = useThemeColor("--color-icon-muted");199 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;
199 const searchEnvironmentIds = useMemo(204 const searchEnvironmentIds = useMemo(
200 () =>205 () =>
201 props.selectedEnvironmentId === null206 props.selectedEnvironmentId === null
...@@ -877,7 +882,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -877,7 +882,7 @@ export function HomeScreen(props: HomeScreenProps) {
877 <View882 <View
878 className="flex-1 items-center justify-center bg-screen px-8"883 className="flex-1 items-center justify-center bg-screen px-8"
879 style={{884 style={{
880 paddingBottom: Math.max(insets.bottom, 24),885 paddingBottom: Math.max(insets.bottom, 24) + iosBottomToolbarClearance,
881 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,886 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
882 }}887 }}
883 >888 >
...@@ -1019,7 +1024,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1019,7 +1024,7 @@ export function HomeScreen(props: HomeScreenProps) {
1019 contentContainerStyle={{1024 contentContainerStyle={{
1020 paddingBottom:1025 paddingBottom:
1021 Platform.OS === "ios"1026 Platform.OS === "ios"
1022 ? Math.max(insets.bottom, 24) + 961027 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1023 : Math.max(insets.bottom, 16) + 88,1028 : Math.max(insets.bottom, 16) + 88,
1024 }}1029 }}
1025 />1030 />
...@@ -1060,16 +1065,19 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1060,16 +1065,19 @@ export function HomeScreen(props: HomeScreenProps) {
1060 scrollEventThrottle={16}1065 scrollEventThrottle={16}
1061 contentContainerStyle={{1066 contentContainerStyle={{
1062 // Android reserves room for the floating new-task FAB1067 // 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".
1064 paddingBottom:1072 paddingBottom:
1065 Platform.OS === "ios"1073 Platform.OS === "ios"
1066 ? Math.max(insets.bottom, 24) + 241074 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance
1067 : Math.max(insets.bottom, 16) + 88,1075 : Math.max(insets.bottom, 16) + 88,
1068 }}1076 }}
1069 scrollIndicatorInsets={1077 scrollIndicatorInsets={
1070 Platform.OS === "ios"1078 Platform.OS === "ios"
1071 ? {1079 ? {
1072 bottom: Math.max(insets.bottom, 16) + 24,1080 bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance,
1073 top: 0,1081 top: 0,
1074 }1082 }
1075 : undefined1083 : undefined
apps/mobile/src/features/layout/native-mail-search-toolbar.ts+11
...@@ -1,5 +1,16 @@...@@ -1,5 +1,16 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";1import 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
3type NativeMailSearchToolbarInput = Omit<14type NativeMailSearchToolbarInput = Omit<
4 HeaderBarButtonMailSearchToolbarItem,15 HeaderBarButtonMailSearchToolbarItem,
5 "type" | "useFallbackSearchField"16 "type" | "useFallbackSearchField"
apps/mobile/src/native/StackHeader.tsx+8-1
...@@ -340,7 +340,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {...@@ -340,7 +340,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
340 return {340 return {
341 type: "spacing",341 type: "spacing",
342 spacing: typeof child.props.width === "number" ? child.props.width : 8,342 spacing: typeof child.props.width === "number" ? child.props.width : 8,
343 };343 flexible: Boolean(child.props.flexible),
344 } as NativeStackHeaderItem;
344 }345 }
345346
346 return null;347 return null;
...@@ -351,6 +352,11 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {...@@ -351,6 +352,11 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
351 Children.forEach(children, (child) => {352 Children.forEach(children, (child) => {
352 const item = convertToolbarChild(child);353 const item = convertToolbarChild(child);
353 if (item) {354 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 }
354 items.push(item);360 items.push(item);
355 }361 }
356 });362 });
...@@ -440,6 +446,7 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {...@@ -440,6 +446,7 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
440NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";446NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
441447
442function NativeHeaderToolbarSpacer(_props: {448function NativeHeaderToolbarSpacer(_props: {
449 readonly flexible?: boolean;
443 readonly sharesBackground?: boolean;450 readonly sharesBackground?: boolean;
444 readonly width?: number;451 readonly width?: number;
445}) {452}) {