| ... | @@ -8,15 +8,12 @@ import { | ... | @@ -8,15 +8,12 @@ import { |
| 8 | type EnvironmentMachineKind, | 8 | type EnvironmentMachineKind, |
| 9 | resolveEnvironmentMachineKind, | 9 | resolveEnvironmentMachineKind, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import type { MenuAction } from "@react-native-menu/menu"; | | |
| 12 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | | |
| 13 | import { SymbolView } from "../../components/AppSymbol"; | | |
| 14 | import { useNavigation } from "@react-navigation/native"; | 11 | import { useNavigation } from "@react-navigation/native"; |
| | 12 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| | 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 15 | import { useCallback, useMemo, useRef, type ComponentProps } from "react"; | 14 | import { useCallback, useMemo, useRef, type ComponentProps } from "react"; |
| 16 | import { | 15 | import { |
| 17 | TextInput, | | |
| 18 | ActivityIndicator, | 16 | ActivityIndicator, |
| 19 | Platform, | | |
| 20 | Pressable, | 17 | Pressable, |
| 21 | RefreshControl, | 18 | RefreshControl, |
| 22 | useWindowDimensions, | 19 | useWindowDimensions, |
| ... | @@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ... | @@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 26 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 23 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 27 | | 24 | |
| 28 | import { AppText as Text } from "../../components/AppText"; | 25 | import { AppText as Text } from "../../components/AppText"; |
| 29 | import { ControlPillMenu } from "../../components/ControlPill"; | | |
| 30 | import { EmptyState } from "../../components/EmptyState"; | 26 | import { EmptyState } from "../../components/EmptyState"; |
| 31 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 27 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 32 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 28 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 33 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | | |
| 34 | import { relativeTime } from "../../lib/time"; | 29 | import { relativeTime } from "../../lib/time"; |
| 35 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 30 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 36 | import { useServerConfigs } from "../../state/entities"; | 31 | import { useServerConfigs } from "../../state/entities"; |
| 37 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 32 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 38 | import { | | |
| 39 | createNativeMailSearchToolbarItem, | | |
| 40 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | | |
| 41 | } from "../layout/native-mail-search-toolbar"; | | |
| 42 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; | 33 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 43 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; | 34 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; |
| 44 | | 35 | |
| ... | @@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment { | ... | @@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment { |
| 47 | readonly label: string; | 38 | readonly label: string; |
| 48 | } | 39 | } |
| 49 | | 40 | |
| 50 | type ArchivedThreadListItem = | | |
| 51 | | { | | |
| 52 | readonly kind: "project"; | | |
| 53 | readonly key: string; | | |
| 54 | readonly environmentLabel: string | null; | | |
| 55 | readonly environmentMachine: EnvironmentMachineKind; | | |
| 56 | readonly project: EnvironmentProject; | | |
| 57 | } | | |
| 58 | | { | | |
| 59 | readonly kind: "thread"; | | |
| 60 | readonly key: string; | | |
| 61 | readonly environmentLabel: string | null; | | |
| 62 | readonly isFirst: boolean; | | |
| 63 | readonly isLast: boolean; | | |
| 64 | readonly thread: EnvironmentThreadShell; | | |
| 65 | }; | | |
| 66 | | | |
| 67 | function ArchivedThreadsHeader(props: { | 41 | function ArchivedThreadsHeader(props: { |
| 68 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; | 42 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; |
| 69 | readonly searchQuery: string; | 43 | readonly searchQuery: string; |
| ... | @@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: { | ... | @@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: { |
| 74 | readonly onSearchQueryChange: (query: string) => void; | 48 | readonly onSearchQueryChange: (query: string) => void; |
| 75 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; | 49 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 76 | }) { | 50 | }) { |
| 77 | const { width } = useWindowDimensions(); | | |
| 78 | const navigation = useNavigation(); | 51 | const navigation = useNavigation(); |
| 79 | const insets = useSafeAreaInsets(); | 52 | const { width } = useWindowDimensions(); |
| 80 | const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; | 53 | const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; |
| 81 | const usesNativeChrome = Platform.OS === "ios"; | | |
| 82 | const usesCompactMailToolbar = | | |
| 83 | Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | | |
| 84 | const androidFilterActions = useMemo<MenuAction[]>( | | |
| 85 | () => [ | | |
| 86 | { | | |
| 87 | id: "environment", | | |
| 88 | title: "Environment", | | |
| 89 | subactions: [ | | |
| 90 | { | | |
| 91 | id: "environment:all", | | |
| 92 | title: "All environments", | | |
| 93 | state: props.selectedEnvironmentId === null ? ("on" as const) : undefined, | | |
| 94 | }, | | |
| 95 | ...props.environments.map((environment) => ({ | | |
| 96 | id: `environment:${environment.environmentId}`, | | |
| 97 | title: environment.label, | | |
| 98 | state: | | |
| 99 | props.selectedEnvironmentId === environment.environmentId | | |
| 100 | ? ("on" as const) | | |
| 101 | : undefined, | | |
| 102 | })), | | |
| 103 | ], | | |
| 104 | }, | | |
| 105 | { | | |
| 106 | id: "sort", | | |
| 107 | title: "Sort by archived date", | | |
| 108 | subactions: [ | | |
| 109 | { | | |
| 110 | id: "sort:newest", | | |
| 111 | title: "Newest first", | | |
| 112 | state: props.sortOrder === "newest" ? ("on" as const) : undefined, | | |
| 113 | }, | | |
| 114 | { | | |
| 115 | id: "sort:oldest", | | |
| 116 | title: "Oldest first", | | |
| 117 | state: props.sortOrder === "oldest" ? ("on" as const) : undefined, | | |
| 118 | }, | | |
| 119 | ], | | |
| 120 | }, | | |
| 121 | ], | | |
| 122 | [props.environments, props.selectedEnvironmentId, props.sortOrder], | | |
| 123 | ); | | |
| 124 | const handleAndroidFilterAction = useCallback( | | |
| 125 | (event: { nativeEvent: { event: string } }) => { | | |
| 126 | const action = event.nativeEvent.event; | | |
| 127 | if (action === "environment:all") { | | |
| 128 | props.onEnvironmentChange(null); | | |
| 129 | } else if (action.startsWith("environment:")) { | | |
| 130 | props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); | | |
| 131 | } else if (action === "sort:newest") { | | |
| 132 | props.onSortOrderChange("newest"); | | |
| 133 | } else if (action === "sort:oldest") { | | |
| 134 | props.onSortOrderChange("oldest"); | | |
| 135 | } | | |
| 136 | }, | | |
| 137 | [props.onEnvironmentChange, props.onSortOrderChange], | | |
| 138 | ); | | |
| 139 | | | |
| 140 | if (Platform.OS === "android") { | | |
| 141 | // Single header row matching the app's Android chrome (AndroidScreenHeader | | |
| 142 | // palette): back chevron, inline search, filter menu. | | |
| 143 | return ( | | |
| 144 | <> | | |
| 145 | <NativeStackScreenOptions options={{ headerShown: false }} /> | | |
| 146 | <View | | |
| 147 | className="border-b border-header-border bg-header px-3 pb-2.5" | | |
| 148 | style={{ | | |
| 149 | paddingTop: Math.max(insets.top, 12), | | |
| 150 | borderBottomWidth: 0, | | |
| 151 | }} | | |
| 152 | > | | |
| 153 | <View className="min-h-12 flex-row items-center gap-2"> | | |
| 154 | <Pressable | | |
| 155 | accessibilityLabel="Navigate up" | | |
| 156 | accessibilityRole="button" | | |
| 157 | hitSlop={8} | | |
| 158 | onPress={() => navigation.goBack()} | | |
| 159 | className="size-11 items-center justify-center" | | |
| 160 | > | | |
| 161 | <SymbolView | | |
| 162 | name="chevron.left" | | |
| 163 | size={24} | | |
| 164 | tintColorClassName="accent-foreground" | | |
| 165 | type="monochrome" | | |
| 166 | /> | | |
| 167 | </Pressable> | | |
| 168 | <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5"> | | |
| 169 | <SymbolView | | |
| 170 | name="magnifyingglass" | | |
| 171 | size={17} | | |
| 172 | tintColorClassName="accent-icon" | | |
| 173 | type="monochrome" | | |
| 174 | /> | | |
| 175 | <TextInput | | |
| 176 | accessibilityLabel="Search archived threads" | | |
| 177 | autoCapitalize="none" | | |
| 178 | onChangeText={props.onSearchQueryChange} | | |
| 179 | value={props.searchQuery} | | |
| 180 | placeholder="Search archived threads" | | |
| 181 | placeholderTextColorClassName="accent-placeholder" | | |
| 182 | className="flex-1 py-2 text-base font-sans text-foreground" | | |
| 183 | /> | | |
| 184 | </View> | | |
| 185 | <ControlPillMenu | | |
| 186 | actions={androidFilterActions} | | |
| 187 | isAnchoredToRight | | |
| 188 | onPressAction={handleAndroidFilterAction} | | |
| 189 | > | | |
| 190 | <Pressable | | |
| 191 | accessibilityLabel="Filter and sort archived threads" | | |
| 192 | accessibilityRole="button" | | |
| 193 | className="size-11 items-center justify-center rounded-full bg-subtle" | | |
| 194 | > | | |
| 195 | <SymbolView | | |
| 196 | name={ | | |
| 197 | hasCustomFilter | | |
| 198 | ? "line.3.horizontal.decrease.circle.fill" | | |
| 199 | : "line.3.horizontal.decrease.circle" | | |
| 200 | } | | |
| 201 | size={16} | | |
| 202 | tintColorClassName="accent-icon" | | |
| 203 | type="monochrome" | | |
| 204 | /> | | |
| 205 | </Pressable> | | |
| 206 | </ControlPillMenu> | | |
| 207 | </View> | | |
| 208 | </View> | | |
| 209 | </> | | |
| 210 | ); | | |
| 211 | } | | |
| 212 | const archiveFilterMenu = { | | |
| 213 | title: "Archived thread options", | | |
| 214 | items: [ | | |
| 215 | { | | |
| 216 | type: "submenu" as const, | | |
| 217 | title: "Environment", | | |
| 218 | items: [ | | |
| 219 | { | | |
| 220 | type: "action" as const, | | |
| 221 | title: "All environments", | | |
| 222 | state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const), | | |
| 223 | onPress: () => props.onEnvironmentChange(null), | | |
| 224 | }, | | |
| 225 | ...props.environments.map((environment) => ({ | | |
| 226 | type: "action" as const, | | |
| 227 | title: environment.label, | | |
| 228 | state: | | |
| 229 | props.selectedEnvironmentId === environment.environmentId | | |
| 230 | ? ("on" as const) | | |
| 231 | : ("off" as const), | | |
| 232 | onPress: () => props.onEnvironmentChange(environment.environmentId), | | |
| 233 | })), | | |
| 234 | ], | | |
| 235 | }, | | |
| 236 | { | | |
| 237 | type: "submenu" as const, | | |
| 238 | title: "Sort by archived date", | | |
| 239 | items: [ | | |
| 240 | { | | |
| 241 | type: "action" as const, | | |
| 242 | title: "Newest first", | | |
| 243 | state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), | | |
| 244 | onPress: () => props.onSortOrderChange("newest"), | | |
| 245 | }, | | |
| 246 | { | | |
| 247 | type: "action" as const, | | |
| 248 | title: "Oldest first", | | |
| 249 | state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), | | |
| 250 | onPress: () => props.onSortOrderChange("oldest"), | | |
| 251 | }, | | |
| 252 | ], | | |
| 253 | }, | | |
| 254 | ], | | |
| 255 | }; | | |
| 256 | | | |
| 257 | return ( | 54 | return ( |
| 258 | <> | 55 | <ScreenHeader |
| 259 | {/* Static header config (glass preset + title) lives in Stack.tsx; only | 56 | title="Archived threads" |
| 260 | dynamic toolbar/search wiring is set here. */} | 57 | sidebar={false} |
| 261 | <NativeStackScreenOptions | 58 | onBack={() => navigation.goBack()} |
| 262 | options={{ | 59 | search={{ |
| 263 | unstable_headerToolbarItems: usesCompactMailToolbar | 60 | value: props.searchQuery, |
| 264 | ? () => [ | 61 | onChangeText: props.onSearchQueryChange, |
| 265 | createNativeMailSearchToolbarItem({ | 62 | placeholder: "Search archived threads", |
| 266 | composeButtonId: "archived-refresh", | 63 | compactPlaceholder: "Search", |
| 267 | composeSystemImageName: "arrow.clockwise", | 64 | mode: "inline", |
| 268 | filterMenu: archiveFilterMenu, | 65 | compactToolbar: width < 700, |
| 269 | filterButtonId: "archived-filter", | 66 | onRefresh: props.onRefresh, |
| 270 | filterSystemImageName: hasCustomFilter | 67 | refreshInToolbar: true, |
| 271 | ? "line.3.horizontal.decrease.circle.fill" | 68 | refreshAccessibilityLabel: "Refresh archived threads", |
| 272 | : "line.3.horizontal.decrease", | 69 | }} |
| 273 | onComposePress: props.onRefresh, | 70 | menus={[ |
| 274 | onSearchTextChange: props.onSearchQueryChange, | 71 | { |
| 275 | placeholder: "Search", | 72 | title: "Archived thread options", |
| 276 | searchTextChangeId: "archived-search-text", | 73 | icon: hasCustomFilter |
| 277 | }), | 74 | ? "line.3.horizontal.decrease.circle.fill" |
| 278 | ] | 75 | : "line.3.horizontal.decrease.circle", |
| 279 | : undefined, | 76 | items: [ |
| 280 | headerSearchBarOptions: usesCompactMailToolbar | 77 | { |
| 281 | ? undefined | 78 | id: "environment", |
| 282 | : { | 79 | title: "Environment", |
| 283 | ...(usesNativeChrome | 80 | items: [ |
| 284 | ? { | 81 | { |
| 285 | allowToolbarIntegration: true, | 82 | id: "environment:all", |
| 286 | // "integratedButton" is an iOS 26 search-bar placement; | 83 | title: "All environments", |
| 287 | // pre-glass iOS keeps the default pull-down placement. | 84 | selected: props.selectedEnvironmentId === null, |
| 288 | ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED | 85 | onPress: () => props.onEnvironmentChange(null), |
| 289 | ? { placement: "integratedButton" as const } | | |
| 290 | : null), | | |
| 291 | } | | |
| 292 | : { | | |
| 293 | placement: "stacked" as const, | | |
| 294 | }), | | |
| 295 | autoCapitalize: "none", | | |
| 296 | hideNavigationBar: false, | | |
| 297 | obscureBackground: false, | | |
| 298 | placeholder: "Search archived threads", | | |
| 299 | onChangeText: (event) => { | | |
| 300 | props.onSearchQueryChange(event.nativeEvent.text); | | |
| 301 | }, | 86 | }, |
| 302 | onCancelButtonPress: () => { | 87 | ...props.environments.map((environment) => ({ |
| 303 | props.onSearchQueryChange(""); | 88 | id: `environment:${environment.environmentId}`, |
| | 89 | title: environment.label, |
| | 90 | selected: props.selectedEnvironmentId === environment.environmentId, |
| | 91 | onPress: () => props.onEnvironmentChange(environment.environmentId), |
| | 92 | })), |
| | 93 | ], |
| | 94 | }, |
| | 95 | { |
| | 96 | id: "sort", |
| | 97 | title: "Sort by archived date", |
| | 98 | items: [ |
| | 99 | { |
| | 100 | id: "sort:newest", |
| | 101 | title: "Newest first", |
| | 102 | selected: props.sortOrder === "newest", |
| | 103 | onPress: () => props.onSortOrderChange("newest"), |
| 304 | }, | 104 | }, |
| 305 | }, | 105 | { |
| 306 | }} | 106 | id: "sort:oldest", |
| 307 | /> | 107 | title: "Oldest first", |
| 308 | | 108 | selected: props.sortOrder === "oldest", |
| 309 | {usesCompactMailToolbar ? null : ( | 109 | onPress: () => props.onSortOrderChange("oldest"), |
| 310 | <NativeHeaderToolbar placement="right"> | 110 | }, |
| 311 | {usesNativeChrome ? ( | 111 | ], |
| 312 | <NativeHeaderToolbar.Button | 112 | }, |
| 313 | accessibilityLabel="Refresh archived threads" | 113 | ], |
| 314 | icon="arrow.clockwise" | 114 | }, |
| 315 | onPress={props.onRefresh} | 115 | ]} |
| 316 | separateBackground | 116 | /> |
| 317 | /> | | |
| 318 | ) : null} | | |
| 319 | <NativeHeaderToolbar.Menu | | |
| 320 | accessibilityLabel="Filter and sort archived threads" | | |
| 321 | icon={ | | |
| 322 | hasCustomFilter | | |
| 323 | ? "line.3.horizontal.decrease.circle.fill" | | |
| 324 | : "line.3.horizontal.decrease.circle" | | |
| 325 | } | | |
| 326 | separateBackground | | |
| 327 | title="Archived thread options" | | |
| 328 | > | | |
| 329 | <NativeHeaderToolbar.Menu title="Environment"> | | |
| 330 | <NativeHeaderToolbar.Label>Environment</NativeHeaderToolbar.Label> | | |
| 331 | <NativeHeaderToolbar.MenuAction | | |
| 332 | isOn={props.selectedEnvironmentId === null} | | |
| 333 | onPress={() => props.onEnvironmentChange(null)} | | |
| 334 | > | | |
| 335 | <NativeHeaderToolbar.Label>All environments</NativeHeaderToolbar.Label> | | |
| 336 | </NativeHeaderToolbar.MenuAction> | | |
| 337 | {props.environments.map((environment) => ( | | |
| 338 | <NativeHeaderToolbar.MenuAction | | |
| 339 | key={environment.environmentId} | | |
| 340 | isOn={props.selectedEnvironmentId === environment.environmentId} | | |
| 341 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | | |
| 342 | > | | |
| 343 | <NativeHeaderToolbar.Label>{environment.label}</NativeHeaderToolbar.Label> | | |
| 344 | </NativeHeaderToolbar.MenuAction> | | |
| 345 | ))} | | |
| 346 | </NativeHeaderToolbar.Menu> | | |
| 347 | | | |
| 348 | <NativeHeaderToolbar.Menu title="Sort by archived date"> | | |
| 349 | <NativeHeaderToolbar.Label>Sort by archived date</NativeHeaderToolbar.Label> | | |
| 350 | <NativeHeaderToolbar.MenuAction | | |
| 351 | isOn={props.sortOrder === "newest"} | | |
| 352 | onPress={() => props.onSortOrderChange("newest")} | | |
| 353 | > | | |
| 354 | <NativeHeaderToolbar.Label>Newest first</NativeHeaderToolbar.Label> | | |
| 355 | </NativeHeaderToolbar.MenuAction> | | |
| 356 | <NativeHeaderToolbar.MenuAction | | |
| 357 | isOn={props.sortOrder === "oldest"} | | |
| 358 | onPress={() => props.onSortOrderChange("oldest")} | | |
| 359 | > | | |
| 360 | <NativeHeaderToolbar.Label>Oldest first</NativeHeaderToolbar.Label> | | |
| 361 | </NativeHeaderToolbar.MenuAction> | | |
| 362 | </NativeHeaderToolbar.Menu> | | |
| 363 | </NativeHeaderToolbar.Menu> | | |
| 364 | </NativeHeaderToolbar> | | |
| 365 | )} | | |
| 366 | </> | | |
| 367 | ); | 117 | ); |
| 368 | } | 118 | } |
| 369 | | 119 | |
| | 120 | type ArchivedThreadListItem = |
| | 121 | | { |
| | 122 | readonly kind: "project"; |
| | 123 | readonly key: string; |
| | 124 | readonly environmentLabel: string | null; |
| | 125 | readonly environmentMachine: EnvironmentMachineKind; |
| | 126 | readonly project: EnvironmentProject; |
| | 127 | } |
| | 128 | | { |
| | 129 | readonly kind: "thread"; |
| | 130 | readonly key: string; |
| | 131 | readonly environmentLabel: string | null; |
| | 132 | readonly isFirst: boolean; |
| | 133 | readonly isLast: boolean; |
| | 134 | readonly thread: EnvironmentThreadShell; |
| | 135 | }; |
| | 136 | |
| 370 | function ProjectGroupLabel(props: { | 137 | function ProjectGroupLabel(props: { |
| 371 | readonly environmentLabel: string | null; | 138 | readonly environmentLabel: string | null; |
| 372 | readonly environmentMachine: EnvironmentMachineKind; | 139 | readonly environmentMachine: EnvironmentMachineKind; |