1import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
2import { useNavigation, useNavigationState } from "@react-navigation/native";
3import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
4import {
5 BackHandler,
6 Keyboard,
7 type TextInputInstance,
8 View,
9 type LayoutChangeEvent,
10} from "react-native";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
12import type { MenuAction } from "@react-native-menu/menu";
13
14import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
15import { CompactBrandTitle } from "../../components/CompactBrandTitle";
16import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton";
17import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
18import { ControlPillMenu } from "../../components/ControlPill";
19import { MaterialSearchField } from "../../components/MaterialSearchField";
20import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
21import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle";
22import { useWorkspaceState } from "../../state/workspace";
23import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
24import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
25
26/** One toolbar height for the compact list and expanded sidebar, including search. */
27export function MaterialThreadListToolbar(props: {
28 readonly searchQuery: string;
29 readonly onSearchQueryChange: (query: string) => void;
30 readonly filterActions: MenuAction[];
31 readonly filterCustomized: boolean;
32 readonly onFilterAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>;
33 readonly onOpenSettings: () => void;
34 readonly onOpenEnvironments: () => void;
35 readonly sidebar?: boolean;
36 readonly onLayout?: (event: LayoutChangeEvent) => void;
37 readonly onRequestVisibility?: () => void;
38}) {
39 const insets = useSafeAreaInsets();
40 const navigation = useNavigation();
41 const workspace = useWorkspaceTabs();
42 const isTabActive = workspace?.isTabActive;
43 const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
44 const listFocused =
45 homeFocused && (workspace === null || workspace.state.selectedTab === "threads");
46 const { fabSize } = useAndroidControlSizing();
47 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
48 const { state } = useWorkspaceState();
49 const { onRequestVisibility, onSearchQueryChange } = props;
50 const searchRef = useRef<TextInputInstance>(null);
51 const [searchOpen, setSearchOpen] = useState(false);
52 const searching = searchOpen || props.searchQuery.length > 0;
53 const openSearch = useCallback(() => {
54 onRequestVisibility?.();
55 setSearchOpen(true);
56 searchRef.current?.focus();
57 return true;
58 }, [onRequestVisibility]);
59 useHardwareKeyboardCommand("focusSearch", openSearch);
60
61 const closeSearch = useCallback(() => {
62 onSearchQueryChange("");
63 setSearchOpen(false);
64 Keyboard.dismiss();
65 }, [onSearchQueryChange]);
66
67 useEffect(() => {
68 if (!searching || !listFocused) return;
69 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
70 // Frozen Home screens can keep effects alive after a detail route opens.
71 const state = navigation.getState();
72 if (
73 !state ||
74 state.routes[state.index]?.name !== "Home" ||
75 isTabActive?.("threads") === false
76 )
77 return false;
78 if (Keyboard.isVisible()) Keyboard.dismiss();
79 else closeSearch();
80 return true;
81 });
82 return () => subscription.remove();
83 }, [closeSearch, searching, listFocused, navigation, isTabActive]);
84
85 const filterIcon = props.filterCustomized
86 ? "line.3.horizontal.decrease.circle.fill"
87 : "line.3.horizontal.decrease.circle";
88 const searchField = (
89 <MaterialSearchField
90 inputRef={searchRef}
91 accessibilityLabel="Search threads"
92 clearAccessibilityLabel="Clear search"
93 placeholder="Search"
94 value={props.searchQuery}
95 onChangeText={onSearchQueryChange}
96 />
97 );
98
99 return (
100 <>
101 <View
102 onLayout={props.onLayout}
103 className={
104 props.sidebar ? "absolute inset-x-0 top-0 z-[4] bg-header px-2" : "bg-header px-2"
105 }
106 style={headerPadding}
107 >
108 <View className="flex-row items-center gap-1" style={{ minHeight: toolbarHeight }}>
109 {searching ? (
110 <>
111 <AndroidHeaderIconButton
112 accessibilityLabel="Close search"
113 icon="arrow.left"
114 onPress={closeSearch}
115 />
116 {searchField}
117 </>
118 ) : (
119 <>
120 {/* Match the visible inset of the trailing 48dp icon button. */}
121 <View className="min-w-0 flex-1 pl-4">
122 <WorkspaceConnectionTitle
123 grow
124 onPress={props.onOpenEnvironments}
125 brand={<CompactBrandTitle allowFontScaling={false} />}
126 />
127 </View>
128 <AndroidHeaderIconButton
129 accessibilityLabel="Search threads"
130 icon="magnifyingglass"
131 onPress={openSearch}
132 />
133 <AndroidHeaderIconButton
134 accessibilityLabel="Open settings"
135 icon="gearshape"
136 onPress={props.onOpenSettings}
137 />
138 </>
139 )}
140 </View>
141 </View>
142 {/* Keep the filter above the New thread FAB at every text size. */}
143 {state.hasConnections ? (
144 <View
145 className="absolute right-5 z-[5]"
146 style={{
147 bottom:
148 (props.sidebar ? Math.max(insets.bottom, 12) + 6 : Math.max(insets.bottom, 16) + 16) +
149 fabSize +
150 8,
151 }}
152 >
153 <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}>
154 {(open) => (
155 <MaterialFloatingActionButton
156 label="Filter threads"
157 icon={filterIcon}
158 onPress={open}
159 />
160 )}
161 </AndroidAnchoredMenu>
162 </View>
163 ) : null}
164 </>
165 );
166}