diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 4f98d3138b..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. @@ -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/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 new file mode 100644 index 0000000000..c45bb817fb --- /dev/null +++ b/apps/mobile/src/ui/shell/header-action.ios.tsx @@ -0,0 +1,59 @@ +import { Button, Host, Image } from '@expo/ui/swift-ui'; +import { + accessibilityAddTraits, + 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 = { + filter: 'line.3.horizontal.decrease', + search: 'magnifyingglass', + settings: 'gearshape', +} as const; + +export function HeaderAction({ icon, label, testID, active = false, 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..0a338920af --- /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, active, 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..f630416162 --- /dev/null +++ b/apps/mobile/src/ui/shell/header-glyph.tsx @@ -0,0 +1,26 @@ +import { useDesignTheme } from '../design/theme'; +import { ActivityFilterIcon, ActivitySearchIcon, SettingsIcon } from './icons'; + +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, active }: { icon: HeaderActionIcon; active: boolean }) { + 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..d1049198df --- /dev/null +++ b/apps/mobile/src/ui/shell/header-icon-button.android.tsx @@ -0,0 +1,37 @@ +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, + selected, + 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 54% rename from apps/mobile/src/ui/shell/settings-button.tsx rename to apps/mobile/src/ui/shell/header-icon-button.tsx index 05131634b4..ebf662346c 100644 --- a/apps/mobile/src/ui/shell/settings-button.tsx +++ b/apps/mobile/src/ui/shell/header-icon-button.tsx @@ -1,18 +1,32 @@ +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; + selected?: boolean | undefined; + onPress(): void; + children: ReactNode; +}; + +export function HeaderIconButton({ + label, + testID, + selected, + onPress, + children, +}: HeaderIconButtonProps) { const theme = useDesignTheme(); return ( ({ width: theme.touch, height: theme.touch, @@ -22,7 +36,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..254e46b61b --- /dev/null +++ b/apps/mobile/test/header-action.test.tsx @@ -0,0 +1,138 @@ +// @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 }), + accessibilityAddTraits: (traits: string[]) => ({ $type: 'accessibilityAddTraits', traits }), + 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; traits?: string[] }[]; + }) => + createElement( + 'button', + { + 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' }), +})); +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(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(); + } 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(); + } +}); + +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 new file mode 100644 index 0000000000..dc4ce2decb --- /dev/null +++ b/changes/2487-mobile-native-header-buttons.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- 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 + +- 手机端聊天列表和 Activity 顶栏的搜索、设置、筛选按钮改用系统原生按钮:iOS 26 及以上为液态玻璃(Liquid Glass)按钮,Android 为 Material 3 图标按钮;更早的 iOS 保持原来的圆形按钮([#2487](https://github.com/BotHarness/DeepSeekBot/pull/2487))。