Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/app/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -300,12 +300,18 @@ const HomeContent = memo(function HomeContent({
}
actions={
<>
<HeaderSearchButton
<HeaderAction
icon="search"
label={t.openSearch}
testID="search-button"
onPress={() => setSearching(true)}
/>
<SettingsButton onPress={() => router.push('/settings')} />
<HeaderAction
icon="settings"
label={t.settings}
testID="settings-button"
onPress={() => router.push('/settings')}
/>
</>
}
>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/ui/design/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 14 additions & 52 deletions apps/mobile/src/ui/shell/activity-toolbar-base.tsx
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -96,30 +96,25 @@ export function ActivityToolbar({
{t.activity}
</Text>
{!unavailable ? (
<ActivityIconButton
<HeaderAction
icon="filter"
label={t.activityOptions}
testID="activity-filter-button"
active={filtered}
onPress={onFilter}
selected={filtered}
>
<ActivityFilterIcon
color={filtered ? theme.color.accent : theme.color.fg}
size={theme.type.title.size}
/>
</ActivityIconButton>
/>
) : null}
{!unavailable ? (
<ActivityIconButton
<HeaderAction
icon="search"
label={t.activitySearch}
testID="activity-search-button"
active={search !== ''}
onPress={() => {
onSearchFocus(true);
setSearching(true);
}}
>
<ActivitySearchIcon
color={search ? theme.color.accent : theme.color.fg}
size={theme.type.title.size}
/>
</ActivityIconButton>
/>
) : null}
</>
)}
Expand All @@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected }}
onPress={onPress}
android_ripple={{ color: theme.layer.canvas.pressed, borderless: true }}
style={({ pressed }) => ({
width: theme.touch,
height: theme.touch,
borderRadius: radius.round,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: pressed ? theme.layer.canvas.pressed : theme.layer.canvas.fill,
})}
>
{children}
</Pressable>
);
}
59 changes: 59 additions & 0 deletions apps/mobile/src/ui/shell/header-action.ios.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HeaderIconButton label={label} testID={testID} selected={active} onPress={onPress}>
<HeaderGlyph icon={icon} active={active} />
</HeaderIconButton>
);
return (
<Host
key={theme.scheme}
testID={testID}
colorScheme={theme.scheme}
style={{ width: theme.touch, height: theme.touch }}
>
<Button
onPress={onPress}
modifiers={[
buttonStyle('glass'),
buttonBorderShape('circle'),
accessibilityLabel(label),
accessibilityAddTraits(active ? ['isSelected'] : []),
foregroundStyle(active ? theme.color.accent : theme.color.fg),
]}
>
<Image
systemName={symbols[icon]}
modifiers={[
font({ textStyle: 'body', weight: 'medium' }),
frame({ width: theme.touch - space.x5, height: theme.touch - space.x5 }),
]}
/>
</Button>
</Host>
);
}
10 changes: 10 additions & 0 deletions apps/mobile/src/ui/shell/header-action.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HeaderIconButton label={label} testID={testID} selected={active} onPress={onPress}>
<HeaderGlyph icon={icon} active={active === true} />
</HeaderIconButton>
);
}
26 changes: 26 additions & 0 deletions apps/mobile/src/ui/shell/header-glyph.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Glyph color={active ? theme.color.accent : theme.color.fg} size={theme.type.title[size]} />
);
}
37 changes: 37 additions & 0 deletions apps/mobile/src/ui/shell/header-icon-button.android.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={selected === undefined ? undefined : { selected }}
onPress={onPress}
android_ripple={{
color: theme.layer.canvas.pressed,
borderless: true,
radius: materialIconButton.container / 2,
}}
style={{
width: theme.touch,
height: theme.touch,
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</Pressable>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
testID="settings-button"
onPress={onPress}
testID={testID}
accessibilityRole="button"
accessibilityLabel={t.settings}
accessibilityLabel={label}
accessibilityState={selected === undefined ? undefined : { selected }}
onPress={onPress}
style={({ pressed }) => ({
width: theme.touch,
height: theme.touch,
Expand All @@ -22,7 +36,7 @@ export function SettingsButton({ onPress }: { onPress(): void }) {
backgroundColor: pressed ? theme.layer.raised.pressed : theme.layer.raised.fill,
})}
>
<SettingsIcon color={theme.color.fg} size={theme.type.title.line} />
{children}
</Pressable>
);
}
Loading
Loading