| ... | ... | @@ -8,15 +8,12 @@ import { |
| 8 | 8 | type EnvironmentMachineKind, |
| 9 | 9 | resolveEnvironmentMachineKind, |
| 10 | 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 | 11 | import { useNavigation } from "@react-navigation/native"; |
| 12 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 13 | import { SymbolView } from "../../components/AppSymbol"; |
| 15 | 14 | import { useCallback, useMemo, useRef, type ComponentProps } from "react"; |
| 16 | 15 | import { |
| 17 | | TextInput, |
| 18 | 16 | ActivityIndicator, |
| 19 | | Platform, |
| 20 | 17 | Pressable, |
| 21 | 18 | RefreshControl, |
| 22 | 19 | useWindowDimensions, |
| ... | ... | @@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 26 | 23 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 27 | 24 | |
| 28 | 25 | import { AppText as Text } from "../../components/AppText"; |
| 29 | | import { ControlPillMenu } from "../../components/ControlPill"; |
| 30 | 26 | import { EmptyState } from "../../components/EmptyState"; |
| 31 | 27 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 32 | 28 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 33 | | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 34 | 29 | import { relativeTime } from "../../lib/time"; |
| 35 | 30 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 36 | 31 | import { useServerConfigs } from "../../state/entities"; |
| 37 | 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 | 33 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 43 | 34 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; |
| 44 | 35 | |
| ... | ... | @@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment { |
| 47 | 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 | 41 | function ArchivedThreadsHeader(props: { |
| 68 | 42 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; |
| 69 | 43 | readonly searchQuery: string; |
| ... | ... | @@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: { |
| 74 | 48 | readonly onSearchQueryChange: (query: string) => void; |
| 75 | 49 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; |
| 76 | 50 | }) { |
| 77 | | const { width } = useWindowDimensions(); |
| 78 | 51 | const navigation = useNavigation(); |
| 79 | | const insets = useSafeAreaInsets(); |
| 52 | const { width } = useWindowDimensions(); |
| 80 | 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 | 54 | return ( |
| 258 | | <> |
| 259 | | {/* Static header config (glass preset + title) lives in Stack.tsx; only |
| 260 | | dynamic toolbar/search wiring is set here. */} |
| 261 | | <NativeStackScreenOptions |
| 262 | | options={{ |
| 263 | | unstable_headerToolbarItems: usesCompactMailToolbar |
| 264 | | ? () => [ |
| 265 | | createNativeMailSearchToolbarItem({ |
| 266 | | composeButtonId: "archived-refresh", |
| 267 | | composeSystemImageName: "arrow.clockwise", |
| 268 | | filterMenu: archiveFilterMenu, |
| 269 | | filterButtonId: "archived-filter", |
| 270 | | filterSystemImageName: hasCustomFilter |
| 271 | | ? "line.3.horizontal.decrease.circle.fill" |
| 272 | | : "line.3.horizontal.decrease", |
| 273 | | onComposePress: props.onRefresh, |
| 274 | | onSearchTextChange: props.onSearchQueryChange, |
| 275 | | placeholder: "Search", |
| 276 | | searchTextChangeId: "archived-search-text", |
| 277 | | }), |
| 278 | | ] |
| 279 | | : undefined, |
| 280 | | headerSearchBarOptions: usesCompactMailToolbar |
| 281 | | ? undefined |
| 282 | | : { |
| 283 | | ...(usesNativeChrome |
| 284 | | ? { |
| 285 | | allowToolbarIntegration: true, |
| 286 | | // "integratedButton" is an iOS 26 search-bar placement; |
| 287 | | // pre-glass iOS keeps the default pull-down placement. |
| 288 | | ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED |
| 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); |
| 55 | <ScreenHeader |
| 56 | title="Archived threads" |
| 57 | sidebar={false} |
| 58 | onBack={() => navigation.goBack()} |
| 59 | search={{ |
| 60 | value: props.searchQuery, |
| 61 | onChangeText: props.onSearchQueryChange, |
| 62 | placeholder: "Search archived threads", |
| 63 | compactPlaceholder: "Search", |
| 64 | mode: "inline", |
| 65 | compactToolbar: width < 700, |
| 66 | onRefresh: props.onRefresh, |
| 67 | refreshInToolbar: true, |
| 68 | refreshAccessibilityLabel: "Refresh archived threads", |
| 69 | }} |
| 70 | menus={[ |
| 71 | { |
| 72 | title: "Archived thread options", |
| 73 | icon: hasCustomFilter |
| 74 | ? "line.3.horizontal.decrease.circle.fill" |
| 75 | : "line.3.horizontal.decrease.circle", |
| 76 | items: [ |
| 77 | { |
| 78 | id: "environment", |
| 79 | title: "Environment", |
| 80 | items: [ |
| 81 | { |
| 82 | id: "environment:all", |
| 83 | title: "All environments", |
| 84 | selected: props.selectedEnvironmentId === null, |
| 85 | onPress: () => props.onEnvironmentChange(null), |
| 301 | 86 | }, |
| 302 | | onCancelButtonPress: () => { |
| 303 | | props.onSearchQueryChange(""); |
| 87 | ...props.environments.map((environment) => ({ |
| 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 | | }, |
| 306 | | }} |
| 307 | | /> |
| 308 | | |
| 309 | | {usesCompactMailToolbar ? null : ( |
| 310 | | <NativeHeaderToolbar placement="right"> |
| 311 | | {usesNativeChrome ? ( |
| 312 | | <NativeHeaderToolbar.Button |
| 313 | | accessibilityLabel="Refresh archived threads" |
| 314 | | icon="arrow.clockwise" |
| 315 | | onPress={props.onRefresh} |
| 316 | | separateBackground |
| 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 | | </> |
| 105 | { |
| 106 | id: "sort:oldest", |
| 107 | title: "Oldest first", |
| 108 | selected: props.sortOrder === "oldest", |
| 109 | onPress: () => props.onSortOrderChange("oldest"), |
| 110 | }, |
| 111 | ], |
| 112 | }, |
| 113 | ], |
| 114 | }, |
| 115 | ]} |
| 116 | /> |
| 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 | 137 | function ProjectGroupLabel(props: { |
| 371 | 138 | readonly environmentLabel: string | null; |
| 372 | 139 | readonly environmentMachine: EnvironmentMachineKind; |