From 3c9c9be88e5f2eba4a44bf472d93301b46ae3a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:44:35 +0000 Subject: [PATCH 1/3] feat(mobile): native Chats header buttons, Liquid Glass on iOS 26 and Material 3 on Android Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012p4JA6z94Erp3Jy4JciiuD --- apps/mobile/DESIGN.md | 2 +- apps/mobile/app/(tabs)/(home)/index.tsx | 14 ++- apps/mobile/src/ui/design/tokens.ts | 3 + .../mobile/src/ui/shell/header-action.ios.tsx | 53 +++++++++ apps/mobile/src/ui/shell/header-action.tsx | 10 ++ apps/mobile/src/ui/shell/header-glyph.tsx | 22 ++++ .../ui/shell/header-icon-button.android.tsx | 30 +++++ ...ings-button.tsx => header-icon-button.tsx} | 20 ++-- apps/mobile/src/ui/shell/header-search.tsx | 66 ++--------- apps/mobile/test/header-action.test.tsx | 103 ++++++++++++++++++ changes/mobile-native-header-buttons.md | 11 ++ 11 files changed, 264 insertions(+), 70 deletions(-) create mode 100644 apps/mobile/src/ui/shell/header-action.ios.tsx create mode 100644 apps/mobile/src/ui/shell/header-action.tsx create mode 100644 apps/mobile/src/ui/shell/header-glyph.tsx create mode 100644 apps/mobile/src/ui/shell/header-icon-button.android.tsx rename apps/mobile/src/ui/shell/{settings-button.tsx => header-icon-button.tsx} (62%) create mode 100644 apps/mobile/test/header-action.test.tsx create mode 100644 changes/mobile-native-header-buttons.md diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 4f98d3138b..e839792d5b 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -98,7 +98,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - **Chats** shows the current Host: - Conversation rows keep the title and timestamp on one horizontal line at every system text size. The title takes the remaining width and truncates with tail ellipsis; the timestamp keeps its own single-line slot. Preview/status and count stay on a second line. A conversation without a timestamp does not reserve an empty metadata line. Preserve native font scaling and the complete accessible title/preview. - - The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree. + - The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. Search and Settings are `HeaderAction` buttons, native to each platform: on iOS 26 and later they are SwiftUI Liquid Glass circles (`buttonStyle('glass')`) with SF Symbols (`magnifyingglass`, `gearshape`), so the system supplies the press, Reduce Transparency and Increase Contrast looks; earlier iOS keeps the opaque `raised` circles. Android uses Material 3 standard icon buttons: no container, a 40dp circular ripple (`materialIconButton.container`) inside the 48dp touch target, like the actions of a Material top app bar. The Android search back and clear buttons use the same icon button. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree. - **+** is the circle beside the bottom navigation island. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet. - **Search** is a magnifying-glass button just left of the Settings gear. Tapping it turns the whole header row into the search field and hides the Host title and Settings, as a Material top app bar opens its search and as iOS expands a toolbar search from its button. The field is the shared navigation glass capsule (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) and focuses at once. iOS closes it with the native circular close button at the trailing end and keeps the system clear button inside the field. Android leads with a back arrow, adds its own clear button while there is a query, and system Back closes search. Closing clears the query and restores the title and Settings. Ending editing with an empty query closes search; with a query, the field stays in the header so the filtered list keeps showing what it matches. Navigation stays visible while the field is focused or holds a query. Nothing floats above the bottom navigation, and the list reserves only the navigation's height. There are no shortcut cards: Activity already gathers what needs the Human. - On Android, swiping a Computer conversation row left reveals Material 3 list actions as 56dp icon buttons with 16dp corners: Mute or Unmute (on this computer), Pin or Unpin, and, when it has unread messages, Mark as read as the end-aligned primary action in the accent colour. They use the existing Host methods (`channelMuteSet`, `rosterBatch`, `channelMarkRead`; CLI `channel-mute`, `roster-batch`, `channel-mark-read`). The same actions are accessibility actions on the row, so TalkBack reaches them without the swipe. Cloud rows have no swipe actions yet. A full swipe does not trigger an action. diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index c0c11706a3..713c902b4f 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -38,10 +38,10 @@ import { } from '../../../src/host/conversation-environments'; import { shownHostName } from '../../../src/ui/design/host-look'; import { Text } from '../../../src/ui/primitives/text'; -import { HeaderSearch, HeaderSearchButton } from '../../../src/ui/shell/header-search'; +import { HeaderAction } from '../../../src/ui/shell/header-action'; +import { HeaderSearch } from '../../../src/ui/shell/header-search'; import { HomeHeader } from '../../../src/ui/shell/home-header'; import { HostDrawerEdge } from '../../../src/ui/shell/host-drawer'; -import { SettingsButton } from '../../../src/ui/shell/settings-button'; import { ConversationPeek, conversationPeek } from '../../../src/ui/shell/conversation-peek'; import { useFloatingTabInset } from '../../../src/ui/shell/floating-tab-inset'; import { conversationHref, openConversation } from '../../../src/ui/shell/open-conversation'; @@ -300,12 +300,18 @@ const HomeContent = memo(function HomeContent({ } actions={ <> - setSearching(true)} /> - router.push('/settings')} /> + router.push('/settings')} + /> } > diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index 9cc7e6a2e7..132a3be506 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -49,6 +49,9 @@ export const materialNavigationBar = { iconLabelGap: 4, elevation: 3, } as const; +export const materialIconButton = { + container: 40, +} as const; export const fab = { size: 56, radius: 16, diff --git a/apps/mobile/src/ui/shell/header-action.ios.tsx b/apps/mobile/src/ui/shell/header-action.ios.tsx new file mode 100644 index 0000000000..692f89a07a --- /dev/null +++ b/apps/mobile/src/ui/shell/header-action.ios.tsx @@ -0,0 +1,53 @@ +import { Button, Host, Image } from '@expo/ui/swift-ui'; +import { + accessibilityLabel, + buttonBorderShape, + buttonStyle, + font, + foregroundStyle, + frame, +} from '@expo/ui/swift-ui/modifiers'; +import { Platform } from 'react-native'; + +import { useDesignTheme } from '../design/theme'; +import { space } from '../design/tokens'; +import { HeaderGlyph, type HeaderActionProps } from './header-glyph'; +import { HeaderIconButton } from './header-icon-button'; + +const symbols = { search: 'magnifyingglass', settings: 'gearshape' } as const; + +export function HeaderAction({ icon, label, testID, onPress }: HeaderActionProps) { + const theme = useDesignTheme(); + if (Number.parseInt(String(Platform.Version), 10) < 26) + return ( + + + + ); + return ( + + + + ); +} diff --git a/apps/mobile/src/ui/shell/header-action.tsx b/apps/mobile/src/ui/shell/header-action.tsx new file mode 100644 index 0000000000..ad6c1fab0f --- /dev/null +++ b/apps/mobile/src/ui/shell/header-action.tsx @@ -0,0 +1,10 @@ +import { HeaderGlyph, type HeaderActionProps } from './header-glyph'; +import { HeaderIconButton } from './header-icon-button'; + +export function HeaderAction({ icon, label, testID, onPress }: HeaderActionProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/ui/shell/header-glyph.tsx b/apps/mobile/src/ui/shell/header-glyph.tsx new file mode 100644 index 0000000000..fc223b7caf --- /dev/null +++ b/apps/mobile/src/ui/shell/header-glyph.tsx @@ -0,0 +1,22 @@ +import { useDesignTheme } from '../design/theme'; +import { ActivitySearchIcon, SettingsIcon } from './icons'; + +export type HeaderActionIcon = 'search' | 'settings'; + +export type HeaderActionProps = { + icon: HeaderActionIcon; + label: string; + testID: string; + onPress(): void; +}; + +const glyphs = { + search: { Glyph: ActivitySearchIcon, size: 'size' }, + settings: { Glyph: SettingsIcon, size: 'line' }, +} as const; + +export function HeaderGlyph({ icon }: { icon: HeaderActionIcon }) { + const theme = useDesignTheme(); + const { Glyph, size } = glyphs[icon]; + return ; +} diff --git a/apps/mobile/src/ui/shell/header-icon-button.android.tsx b/apps/mobile/src/ui/shell/header-icon-button.android.tsx new file mode 100644 index 0000000000..ce437c91f4 --- /dev/null +++ b/apps/mobile/src/ui/shell/header-icon-button.android.tsx @@ -0,0 +1,30 @@ +import { Pressable } from 'react-native'; + +import { useDesignTheme } from '../design/theme'; +import { materialIconButton } from '../design/tokens'; +import type { HeaderIconButtonProps } from './header-icon-button'; + +export function HeaderIconButton({ label, testID, onPress, children }: HeaderIconButtonProps) { + const theme = useDesignTheme(); + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/ui/shell/settings-button.tsx b/apps/mobile/src/ui/shell/header-icon-button.tsx similarity index 62% rename from apps/mobile/src/ui/shell/settings-button.tsx rename to apps/mobile/src/ui/shell/header-icon-button.tsx index 05131634b4..d00f3551c3 100644 --- a/apps/mobile/src/ui/shell/settings-button.tsx +++ b/apps/mobile/src/ui/shell/header-icon-button.tsx @@ -1,18 +1,24 @@ +import type { ReactNode } from 'react'; import { Pressable } from 'react-native'; import { useDesignTheme } from '../design/theme'; import { radius } from '../design/tokens'; -import { t } from '../strings'; -import { SettingsIcon } from './icons'; -export function SettingsButton({ onPress }: { onPress(): void }) { +export type HeaderIconButtonProps = { + label: string; + testID?: string | undefined; + onPress(): void; + children: ReactNode; +}; + +export function HeaderIconButton({ label, testID, onPress, children }: HeaderIconButtonProps) { const theme = useDesignTheme(); return ( ({ width: theme.touch, height: theme.touch, @@ -22,7 +28,7 @@ export function SettingsButton({ onPress }: { onPress(): void }) { backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill, })} > - + {children} ); } diff --git a/apps/mobile/src/ui/shell/header-search.tsx b/apps/mobile/src/ui/shell/header-search.tsx index cfb12c0f84..701090ef5e 100644 --- a/apps/mobile/src/ui/shell/header-search.tsx +++ b/apps/mobile/src/ui/shell/header-search.tsx @@ -1,31 +1,14 @@ -import { useRef, type ReactNode } from 'react'; -import { BackHandler, Keyboard, Platform, Pressable, View, type ViewInstance } from 'react-native'; +import { useRef } from 'react'; +import { BackHandler, Keyboard, Platform, View, type ViewInstance } from 'react-native'; import { useDesignTheme } from '../design/theme'; -import { radius } from '../design/tokens'; import { SearchField } from '../primitives/search-field'; import { useMounted } from '../mounted'; import { t } from '../strings'; -import { ActivitySearchIcon, ChevronLeftIcon, CloseIcon } from './icons'; +import { HeaderIconButton } from './header-icon-button'; +import { ChevronLeftIcon, CloseIcon } from './icons'; import { NavigationAction } from './navigation-action'; -export function HeaderSearchButton({ - label, - testID, - onPress, -}: { - label: string; - testID?: string; - onPress(): void; -}) { - const theme = useDesignTheme(); - return ( - - - - ); -} - export function HeaderSearch({ value, placeholder, @@ -59,9 +42,9 @@ export function HeaderSearch({ return ( <> {android ? ( - + - + ) : null} ) : value !== '' ? ( - onChangeText('')}> + onChangeText('')}> - + ) : null } /> @@ -87,36 +70,3 @@ export function HeaderSearch({ ); } - -function HeaderSearchAction({ - label, - testID, - onPress, - children, -}: { - label: string; - testID?: string | undefined; - onPress(): void; - children: ReactNode; -}) { - const theme = useDesignTheme(); - return ( - ({ - width: theme.touch, - height: theme.touch, - borderRadius: radius.round, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill, - })} - > - {children} - - ); -} diff --git a/apps/mobile/test/header-action.test.tsx b/apps/mobile/test/header-action.test.tsx new file mode 100644 index 0000000000..ed63f7c738 --- /dev/null +++ b/apps/mobile/test/header-action.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { act, createElement, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +const platform = vi.hoisted(() => ({ OS: 'ios', Version: 26 })); +vi.mock('react-native', async () => ({ + ...(await import('./react-native-shim')), + Platform: platform, +})); +vi.mock('@expo/ui/swift-ui/modifiers', () => ({ + accessibilityLabel: (label: string) => ({ $type: 'accessibilityLabel', label }), + buttonStyle: (style: string) => ({ $type: 'buttonStyle', style }), + buttonBorderShape: () => ({}), + font: () => ({}), + foregroundStyle: () => ({}), + frame: () => ({}), +})); +vi.mock('@expo/ui/swift-ui', () => ({ + Host: ({ children, testID }: { children: ReactNode; testID?: string }) => + createElement('div', { 'data-testid': testID }, children), + Image: ({ systemName }: { systemName: string }) => createElement('i', { title: systemName }), + Button: ({ + onPress, + children, + modifiers, + }: { + onPress(): void; + children: ReactNode; + modifiers: { $type: string; label?: string; style?: string }[]; + }) => + createElement( + 'button', + { + onClick: onPress, + 'aria-label': modifiers.find((m) => m.$type === 'accessibilityLabel')?.label, + 'data-style': modifiers.find((m) => m.$type === 'buttonStyle')?.style, + }, + children, + ), +})); +vi.mock('../src/ui/shell/icons', () => ({ + ActivitySearchIcon: () => createElement('svg', { 'data-icon': 'search' }), + SettingsIcon: () => createElement('svg', { 'data-icon': 'settings' }), +})); +import { HeaderAction } from '../src/ui/shell/header-action.ios'; +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +async function render(version: number, onPress: () => void) { + platform.Version = version; + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + await act(async () => + root.render( + <> + + + , + ), + ); + return { + host, + dispose() { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +it('chat list header actions are Liquid Glass SF Symbol buttons on iOS 26', async () => { + const onPress = vi.fn(); + const view = await render(26, onPress); + try { + const buttons = [...view.host.querySelectorAll('button')]; + expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual(['Search', 'Settings']); + expect(buttons.map((b) => b.dataset['style'])).toEqual(['glass', 'glass']); + expect(buttons.map((b) => b.querySelector('i')?.title)).toEqual([ + 'magnifyingglass', + 'gearshape', + ]); + expect(view.host.querySelector('[data-testid="settings-button"] button')).not.toBeNull(); + await act(async () => buttons[1]!.click()); + expect(onPress).toHaveBeenCalledOnce(); + } finally { + view.dispose(); + } +}); + +it('keeps the drawn raised buttons before iOS 26', async () => { + const onPress = vi.fn(); + const view = await render(18, onPress); + try { + const buttons = [...view.host.querySelectorAll('button')]; + expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual(['Search', 'Settings']); + expect(buttons.map((b) => b.dataset['testid'])).toEqual(['search-button', 'settings-button']); + expect(view.host.querySelector('i')).toBeNull(); + expect(view.host.querySelector('[data-icon="settings"]')).not.toBeNull(); + await act(async () => buttons[0]!.click()); + expect(onPress).toHaveBeenCalledOnce(); + } finally { + view.dispose(); + } +}); diff --git a/changes/mobile-native-header-buttons.md b/changes/mobile-native-header-buttons.md new file mode 100644 index 0000000000..2ed987614f --- /dev/null +++ b/changes/mobile-native-header-buttons.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- On the phone's Chats list, Search and Settings are now native buttons: Liquid Glass on iOS 26 and later, and Material 3 icon buttons on Android. Earlier iOS keeps the current round buttons. + +## zh + +- 手机端聊天列表右上角的搜索和设置改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮。 From 1a7e7c64857dbc6e94d28dcd8d210865277d1582 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:45:02 +0000 Subject: [PATCH 2/3] docs(changes): link the native header buttons fragment to #2487 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012p4JA6z94Erp3Jy4JciiuD --- ...header-buttons.md => 2487-mobile-native-header-buttons.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename changes/{mobile-native-header-buttons.md => 2487-mobile-native-header-buttons.md} (62%) diff --git a/changes/mobile-native-header-buttons.md b/changes/2487-mobile-native-header-buttons.md similarity index 62% rename from changes/mobile-native-header-buttons.md rename to changes/2487-mobile-native-header-buttons.md index 2ed987614f..70760d0406 100644 --- a/changes/mobile-native-header-buttons.md +++ b/changes/2487-mobile-native-header-buttons.md @@ -4,8 +4,8 @@ section: Changed ## en -- On the phone's Chats list, Search and Settings are now native buttons: Liquid Glass on iOS 26 and later, and Material 3 icon buttons on Android. Earlier iOS keeps the current round buttons. +- On the phone's Chats list, Search and Settings are now native buttons: Liquid Glass on iOS 26 and later, and Material 3 icon buttons on Android. Earlier iOS keeps the current round buttons ([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487)). ## zh -- 手机端聊天列表右上角的搜索和设置改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮。 +- 手机端聊天列表右上角的搜索和设置改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487))。 From 12e6de4d2de3f4511ff6c55b3d8167a5ed7d0a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:48:20 +0000 Subject: [PATCH 3/3] feat(mobile): native Activity header buttons through HeaderAction Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012p4JA6z94Erp3Jy4JciiuD --- apps/mobile/DESIGN.md | 2 +- .../src/ui/shell/activity-toolbar-base.tsx | 66 ++++--------------- .../mobile/src/ui/shell/header-action.ios.tsx | 16 +++-- apps/mobile/src/ui/shell/header-action.tsx | 6 +- apps/mobile/src/ui/shell/header-glyph.tsx | 12 ++-- .../ui/shell/header-icon-button.android.tsx | 9 ++- .../src/ui/shell/header-icon-button.tsx | 10 ++- apps/mobile/test/header-action.test.tsx | 37 ++++++++++- changes/2487-mobile-native-header-buttons.md | 4 +- 9 files changed, 92 insertions(+), 70 deletions(-) diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index e839792d5b..8a16f08d17 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -45,7 +45,7 @@ Build a ⋯ menu with `ActionMenuButton` (`src/ui/primitives/action-menu-button` ## Activity query controls - Activity has two header entries: **Filter and sort** and **Search Activity**. Sorting is part of the same options sheet as category, unread-only, Bot and conversation filters. -- iOS uses native navigation toolbar buttons: the system supplies Liquid Glass on iOS 26 and its supported appearance on earlier versions. Active search replaces the title, Filter and Search buttons with the same header search field as Chats, in the measured header row, so accessibility text can grow without clipping. Android uses opaque Material-style icon actions and its native text input. +- Filter and Search are the same `HeaderAction` buttons as the Chats header: Liquid Glass with SF Symbols (`line.3.horizontal.decrease`, `magnifyingglass`) on iOS 26 and later, the opaque `raised` circles on earlier iOS, and Material 3 standard icon buttons on Android. A button whose filter or query applies shows its glyph in `accent` and is marked selected for accessibility. Active search replaces the title, Filter and Search buttons with the same header search field as Chats, in the measured header row, so accessibility text can grow without clipping. Android uses its native text input. - The iOS options sheet uses system page-sheet presentation with swipe dismissal, inset grouped rows and trailing checkmarks. Android uses the modal bottom-sheet surface, leading Material 3 radio buttons (Compose `RadioButton` through the `RadioMark` primitive) and system Back dismissal. Unread-only uses the `Toggle` primitive, so each platform gets its native switch. Long option lists virtualize and scroll; footer actions remain reachable. - Options apply immediately and remain selected after swipe dismissal or Android Back; there are no Cancel/Done actions. Reset all immediately restores default ordering and the Needs you tab, with all Bots/conversations and unread-only off. Search is independent of this reset. The search field keeps the header's outer gutters; iOS search keeps its built-in clear control. Tapping blank list space or ending editing returns the header to its title and buttons and preserves the query for reopening, with the Search button tinted while a query applies, so Filter and sort stay reachable during a search. The close button (iOS) or back arrow and system Back (Android) clears the query. The focused Activity screen owns status-bar contrast for its current appearance. diff --git a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx index 179ece08ea..c92460afdf 100644 --- a/apps/mobile/src/ui/shell/activity-toolbar-base.tsx +++ b/apps/mobile/src/ui/shell/activity-toolbar-base.tsx @@ -1,14 +1,14 @@ -import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react'; +import { useImperativeHandle, useState, type Ref } from 'react'; import { Stack } from 'expo-router'; -import { Keyboard, Pressable, View } from 'react-native'; +import { Keyboard, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDesignTheme } from '../design/theme'; -import { radius, space } from '../design/tokens'; +import { space } from '../design/tokens'; import { Text } from '../primitives/text'; import { t } from '../strings'; +import { HeaderAction } from './header-action'; import { HeaderSearch } from './header-search'; -import { ActivityFilterIcon, ActivitySearchIcon } from './icons'; export type ActivityToolbarHandle = { dismissSearch(): void }; @@ -96,30 +96,25 @@ export function ActivityToolbar({ {t.activity} {!unavailable ? ( - - - + /> ) : null} {!unavailable ? ( - { onSearchFocus(true); setSearching(true); }} - > - - + /> ) : null} )} @@ -128,36 +123,3 @@ export function ActivityToolbar({ ); } - -function ActivityIconButton({ - label, - selected = false, - onPress, - children, -}: { - label: string; - selected?: boolean; - onPress(): void; - children: ReactNode; -}) { - const theme = useDesignTheme(); - return ( - ({ - width: theme.touch, - height: theme.touch, - borderRadius: radius.round, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: pressed ? theme.layer.canvas.pressed : theme.layer.canvas.fill, - })} - > - {children} - - ); -} diff --git a/apps/mobile/src/ui/shell/header-action.ios.tsx b/apps/mobile/src/ui/shell/header-action.ios.tsx index 692f89a07a..c45bb817fb 100644 --- a/apps/mobile/src/ui/shell/header-action.ios.tsx +++ b/apps/mobile/src/ui/shell/header-action.ios.tsx @@ -1,5 +1,6 @@ import { Button, Host, Image } from '@expo/ui/swift-ui'; import { + accessibilityAddTraits, accessibilityLabel, buttonBorderShape, buttonStyle, @@ -14,14 +15,18 @@ import { space } from '../design/tokens'; import { HeaderGlyph, type HeaderActionProps } from './header-glyph'; import { HeaderIconButton } from './header-icon-button'; -const symbols = { search: 'magnifyingglass', settings: 'gearshape' } as const; +const symbols = { + filter: 'line.3.horizontal.decrease', + search: 'magnifyingglass', + settings: 'gearshape', +} as const; -export function HeaderAction({ icon, label, testID, onPress }: HeaderActionProps) { +export function HeaderAction({ icon, label, testID, active = false, onPress }: HeaderActionProps) { const theme = useDesignTheme(); if (Number.parseInt(String(Platform.Version), 10) < 26) return ( - - + + ); return ( @@ -37,7 +42,8 @@ export function HeaderAction({ icon, label, testID, onPress }: HeaderActionProps buttonStyle('glass'), buttonBorderShape('circle'), accessibilityLabel(label), - foregroundStyle(theme.color.fg), + accessibilityAddTraits(active ? ['isSelected'] : []), + foregroundStyle(active ? theme.color.accent : theme.color.fg), ]} > - + + ); } diff --git a/apps/mobile/src/ui/shell/header-glyph.tsx b/apps/mobile/src/ui/shell/header-glyph.tsx index fc223b7caf..f630416162 100644 --- a/apps/mobile/src/ui/shell/header-glyph.tsx +++ b/apps/mobile/src/ui/shell/header-glyph.tsx @@ -1,22 +1,26 @@ import { useDesignTheme } from '../design/theme'; -import { ActivitySearchIcon, SettingsIcon } from './icons'; +import { ActivityFilterIcon, ActivitySearchIcon, SettingsIcon } from './icons'; -export type HeaderActionIcon = 'search' | 'settings'; +export type HeaderActionIcon = 'filter' | 'search' | 'settings'; export type HeaderActionProps = { icon: HeaderActionIcon; label: string; testID: string; + active?: boolean; onPress(): void; }; const glyphs = { + filter: { Glyph: ActivityFilterIcon, size: 'size' }, search: { Glyph: ActivitySearchIcon, size: 'size' }, settings: { Glyph: SettingsIcon, size: 'line' }, } as const; -export function HeaderGlyph({ icon }: { icon: HeaderActionIcon }) { +export function HeaderGlyph({ icon, active }: { icon: HeaderActionIcon; active: boolean }) { const theme = useDesignTheme(); const { Glyph, size } = glyphs[icon]; - return ; + return ( + + ); } diff --git a/apps/mobile/src/ui/shell/header-icon-button.android.tsx b/apps/mobile/src/ui/shell/header-icon-button.android.tsx index ce437c91f4..d1049198df 100644 --- a/apps/mobile/src/ui/shell/header-icon-button.android.tsx +++ b/apps/mobile/src/ui/shell/header-icon-button.android.tsx @@ -4,13 +4,20 @@ import { useDesignTheme } from '../design/theme'; import { materialIconButton } from '../design/tokens'; import type { HeaderIconButtonProps } from './header-icon-button'; -export function HeaderIconButton({ label, testID, onPress, children }: HeaderIconButtonProps) { +export function HeaderIconButton({ + label, + testID, + selected, + onPress, + children, +}: HeaderIconButtonProps) { const theme = useDesignTheme(); return ( ({ width: theme.touch, diff --git a/apps/mobile/test/header-action.test.tsx b/apps/mobile/test/header-action.test.tsx index ed63f7c738..254e46b61b 100644 --- a/apps/mobile/test/header-action.test.tsx +++ b/apps/mobile/test/header-action.test.tsx @@ -9,6 +9,7 @@ vi.mock('react-native', async () => ({ })); vi.mock('@expo/ui/swift-ui/modifiers', () => ({ accessibilityLabel: (label: string) => ({ $type: 'accessibilityLabel', label }), + accessibilityAddTraits: (traits: string[]) => ({ $type: 'accessibilityAddTraits', traits }), buttonStyle: (style: string) => ({ $type: 'buttonStyle', style }), buttonBorderShape: () => ({}), font: () => ({}), @@ -26,7 +27,7 @@ vi.mock('@expo/ui/swift-ui', () => ({ }: { onPress(): void; children: ReactNode; - modifiers: { $type: string; label?: string; style?: string }[]; + modifiers: { $type: string; label?: string; style?: string; traits?: string[] }[]; }) => createElement( 'button', @@ -34,11 +35,15 @@ vi.mock('@expo/ui/swift-ui', () => ({ onClick: onPress, 'aria-label': modifiers.find((m) => m.$type === 'accessibilityLabel')?.label, 'data-style': modifiers.find((m) => m.$type === 'buttonStyle')?.style, + 'aria-pressed': modifiers.some( + (m) => m.$type === 'accessibilityAddTraits' && m.traits?.includes('isSelected'), + ), }, children, ), })); vi.mock('../src/ui/shell/icons', () => ({ + ActivityFilterIcon: () => createElement('svg', { 'data-icon': 'filter' }), ActivitySearchIcon: () => createElement('svg', { 'data-icon': 'search' }), SettingsIcon: () => createElement('svg', { 'data-icon': 'settings' }), })); @@ -78,6 +83,7 @@ it('chat list header actions are Liquid Glass SF Symbol buttons on iOS 26', asyn 'magnifyingglass', 'gearshape', ]); + expect(buttons.map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'false']); expect(view.host.querySelector('[data-testid="settings-button"] button')).not.toBeNull(); await act(async () => buttons[1]!.click()); expect(onPress).toHaveBeenCalledOnce(); @@ -101,3 +107,32 @@ it('keeps the drawn raised buttons before iOS 26', async () => { view.dispose(); } }); + +it('marks an active Activity filter as selected on both paths', async () => { + for (const version of [26, 18]) { + platform.Version = version; + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + await act(async () => + root.render( + {}} + />, + ), + ); + const button = host.querySelector('button')!; + if (version === 26) { + expect(button.querySelector('i')?.title).toBe('line.3.horizontal.decrease'); + expect(button.getAttribute('aria-pressed')).toBe('true'); + } else { + expect(button.getAttribute('aria-selected')).toBe('true'); + } + act(() => root.unmount()); + host.remove(); + } +}); diff --git a/changes/2487-mobile-native-header-buttons.md b/changes/2487-mobile-native-header-buttons.md index 70760d0406..dc4ce2decb 100644 --- a/changes/2487-mobile-native-header-buttons.md +++ b/changes/2487-mobile-native-header-buttons.md @@ -4,8 +4,8 @@ section: Changed ## en -- On the phone's Chats list, Search and Settings are now native buttons: Liquid Glass on iOS 26 and later, and Material 3 icon buttons on Android. Earlier iOS keeps the current round buttons ([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487)). +- On the phone's Chats and Activity headers, the Search, Settings and Filter buttons are now native buttons: Liquid Glass on iOS 26 and later, and Material 3 icon buttons on Android. Earlier iOS keeps the current round buttons ([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487)). ## zh -- 手机端聊天列表右上角的搜索和设置改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487))。 +- 手机端聊天列表和 Activity 顶栏的搜索、设置、筛选按钮改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487))。