From 0ea9003be02c0a65082875ce9d75b1b24883ae8c Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 18:49:19 +0900 Subject: [PATCH] fix(mobile): simplify Chats row menus and share gutters Remove the permanent overflow column. Preserve shared actions through Android long press, iOS Link preview context menus, swipe and screen-reader actions, including mark-read. Use shared Chats gutters with avatar slot compensation and retain #2554 text layout. Source-only candidate: targeted existing formatter and git diff --check passed. Tests/typecheck and native geometry or interaction checks await CI/test-center allocation; no overflow root cause is claimed. Issue: #2588 Agent-Task: codex/local/01a16196-5d76-7442-8c66-95cdef39752c --- .../features/phone-chat-row-actions.md | 9 +- apps/mobile/DESIGN.md | 3 +- apps/mobile/src/ui/chat/conversation-row.tsx | 68 ++-- .../ui/chat/conversation-swipe-actions.tsx | 4 +- apps/mobile/src/ui/design/tokens.ts | 1 + .../mobile/src/ui/shell/conversation-peek.tsx | 28 +- apps/mobile/src/ui/shell/home-header.tsx | 4 +- apps/mobile/test/home-search.test.tsx | 309 +++++++++++------- changes/2588-chat-row-context-actions.md | 11 + 9 files changed, 277 insertions(+), 160 deletions(-) create mode 100644 changes/2588-chat-row-context-actions.md diff --git a/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md b/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md index d60d9fad09..fa247517dc 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md +++ b/.agents/skills/verify-deepseekbot/features/phone-chat-row-actions.md @@ -3,22 +3,22 @@ ## Sub-features - `swipe`: Host DM/Channel rows reveal Pin/Unpin, Add to section and Mute/Unmute; full swipe does not execute. Vertical scrolling and iOS left-edge drawer ownership remain intact. -- `menu`: the native row menu and accessibility actions use the same callbacks; Mark as read remains available when unread. iOS conversation preview still opens the intended chat once. +- `menu`: Android long press and the iOS Link preview/context menu use the same callbacks as row accessibility actions; there is no persistent overflow button; Mark as read remains available when unread. iOS conversation preview still opens the intended chat once. - `sections`: the native sheet reads the captured Remote Host's roster and assigns an existing section via `channelAssign`. New section uses `sectionCreate`, then the returned ID with `channelAssign`. No device-local section persistence. - `recovery`: creation failure preserves the name; assignment failure preserves the confirmed section ID and retries assignment only; refresh failure retries refresh only. Host changes hide old choices and refuse continuation. An already-sent write stays on its original Host. ## How to get to it (user POV) -Open Chats with a paired Host and a DM or group Channel. Swipe a row left or open its actions menu. Choose Add to section, select an existing section or tap New section, enter a name and tap Create and add. +Open Chats with a paired Host and a DM or group Channel. Swipe a row left or long-press it for the full menu (with the existing preview on iOS). Choose Add to section, select an existing section or tap New section, enter a name and tap Create and add. ## Driving it with verify-app Use the assigned [shared device QA](../../../../docs/agents/device-qa.md) runner and [Windows Android lane](../../../../docs/agents/android-device-qa.md). No model is needed. Use two disposable paired Hosts with neutral names and distinct sections; seed a DM, unread conversation and group Channel through existing CLI operations. Record PR/batch SHA, binary provenance, OS, viewport and actual OS text category/percentage. -1. Capture base and candidate on iPhone and Android at default/intermediate/maximum system text, light/dark, matching locale, viewport and test data. Capture closed row, revealed actions, open menu, section chooser, new-section keyboard and retry states. Web output is supplementary only. +1. Capture base and candidate on iPhone and Android at default/intermediate/maximum system text, light/dark, matching locale, viewport and test data. Capture closed row, fully-open settled actions, Android long-press menu, iOS preview/context menu, section chooser, new-section keyboard and retry states. No persistent overflow button or empty action column may remain. Record screen/list/ancestor clipping bounds and all three action bounds separately from hit regions; verify the trailing gutter and tap each action. Compare the header icon’s visible drawing edge with the avatar edge, not the header touch-frame boundary. Include narrow/wide viewports. Web output is supplementary only. 2. Swipe `conversation-row-` left outside the edge. Verify exactly three actions, no execution on a full swipe, and that each tap closes the rail without opening the conversation. Exercise Pin/Unpin and Mute/Unmute via both swipe and the menu; read the Host's roster/mute facts and compare after refresh/relaunch. Cloud rows and Bots without an existing DM must not receive Host actions. 3. On iOS start a rightward swipe inside 24 points (`EDGE_WIDTH`), just inside/outside its boundary, over multiple row positions. The drawer must win inside the zone; row swipe cannot activate there in either direction. Check vertical scroll, swipe-close and tap-close outside the edge. Long-press for the existing chat preview and open it; one tap/preview commit opens one destination. -4. With VoiceOver/TalkBack, open the row's named actions menu and run each action without swiping. Check row accessibility actions too; hidden rail buttons must not duplicate focus stops. Mark as read remains available for an unread row. Use Reduce Motion and Android ripple; repeat long names and orientation changes. +4. With VoiceOver/TalkBack, use the row’s custom accessibility actions to run every action without swiping or long pressing. Also check the platform context menu; hidden rail buttons must not duplicate focus stops. Mark as read remains available for an unread row. Use Reduce Motion and Android ripple; repeat long names and orientation changes. 5. Add to section opens the owning Host's roster sections. Choose one and read `roster-get` on that Host: the Channel belongs to the selected section. Repeat for a Bot DM and a group. Desktop changes must appear through the existing refresh/subscription path. Collapsed sections/pins may affect where Chats shows the row. 6. On an empty section list, New section must remain available. Enter a long localized name with the actual soft keyboard, CJK composition, caret movement and live default→maximum→default size changes. Reach Create and add and Back without hiding text under the keyboard. Verify exactly one section and the membership on the originating Host. Reject whitespace; cancel before submit writes nothing. Reopen and confirm Host persistence. 7. Delay/fail `channelAssign` after successful `sectionCreate` in a disposable QA transport. The sheet shows the saved name and assignment error. Retry must use that ID, never another create; count sections on the Host. Fail refresh after assignment: Retry must not repeat writes. A failed create retains the draft and offers Back to inspect refreshed sections. Deterministic transport tests cover exact call ordering; screenshots alone cannot prove it. @@ -30,3 +30,4 @@ Use the assigned [shared device QA](../../../../docs/agents/device-qa.md) runner - `sectionCreate` has no idempotency key. A lost creation response requires inspecting the Host list before a fresh create; the confirmed-create/failed-assign path has a safe ID-preserving retry. - This flow creates and assigns sections only. Rename/delete/reorder and cross-Host aggregation are out of scope. - No shared devices, Metro, QA Hosts or builds may start outside the owner-cut batch. The Windows machine cannot substitute source tests for iPhone acceptance. +- The previous Android capture showed three fully visible action bounds and a trailing gap; it does not rule out another state’s ancestor clipping or establish hit-testing. Do not label the gutter adjustment a proven overflow fix before the native loop runs. diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index e92431b4fc..17f1d81270 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -107,7 +107,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - 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. The symbol follows Dynamic Type only up to `xxxLarge`, as system toolbar symbols do, so at accessibility sizes it stays inside its fixed glass circle while the button keeps its label; 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 iOS and Android, swiping a Host conversation row left reveals **Pin / Unpin**, **Add to section**, and **Mute / Unmute**. ReanimatedSwipeable owns the gesture on the UI thread; the iOS recognizer excludes the drawer's `EDGE_WIDTH` so a swipe cannot start there. Reveal uses existing 56-point icon targets (Android keeps 16dp corners and ripple), with full localized accessibility labels; a full swipe never commits an action. The native row menu and row accessibility actions expose the same callbacks, with **Mark as read** retained when applicable. iOS keeps its native conversation preview inside the row, separate from the menu trigger. Hidden swipe buttons leave the accessibility tree. Cloud rows have no Host swipe operations. + - On iOS and Android, swiping a Host conversation row left reveals **Pin / Unpin**, **Add to section**, and **Mute / Unmute**. ReanimatedSwipeable owns the gesture on the UI thread; the iOS recognizer excludes the drawer's `EDGE_WIDTH` so a swipe cannot start there. Reveal uses existing 56-point icon targets (Android keeps 16dp corners and ripple), with full localized accessibility labels; a full swipe never commits an action. Chats rows have no persistent overflow button or reserved menu column. Android long press opens the full native action menu; iOS places the full menu in the existing Link preview/context menu without a competing long-press handler. Row accessibility actions expose the same callbacks, with **Mark as read** retained when applicable. This Human-approved row exception keeps secondary commands in swipe/context/accessibility actions while preserving iOS preview. Hidden swipe buttons leave the accessibility tree. Cloud rows have no Host swipe operations. + - Chats uses the shared `chatList.gutter` (16 points) for the header, row content and swipe rail. The row compensates for the avatar slot’s 3-point inset so the avatar starts at the content gutter; the header icon container starts at the same gutter. Preserve each native glyph’s own drawing bounds. The rail retains its trailing gutter and 56-point targets. Verify visible glyph/avatar alignment, actual fully-open clipping and hit regions on device; matching layout constants or accessibility bounds alone do not prove optical alignment or explain the reported clipping. Keep #2554’s horizontal title/time and single-line preview at large text. - **Add to section** opens a native form sheet bound to that conversation's Remote Host and Channel. The virtualized chooser reads the same Host roster snapshot/subscription as Chats; sections are persisted on that Host, never in device-local storage or a cross-Host section list. Choose an existing section or use **New section** to enter a name in a keyboard-aware form and **Create and add**. Names are trimmed and must be nonempty, as on desktop. `sectionCreate` returns the Host's section ID, then `channelAssign` adds the conversation, and the existing Host refresh updates Chats. Pin/mute/read retain `rosterBatch`, `channelMuteSet` and `channelMarkRead`; no new Host API or CLI operation is introduced. - The section sheet captures its Host client; a Host switch hides stale choices and refuses each further write/continuation. Busy feedback appears immediately, and rapid submission is guarded. Creation, assignment and refresh failures have distinct messages. A confirmed section ID survives assignment failure in the open sheet, so Retry only assigns it; a refresh retry does not repeat writes. A creation failure keeps the draft and directs the Human to check the refreshed Host list before trying again, because the existing create API has no idempotency key. Dismissing an in-flight create cannot undo a completed Host write; the section remains available from that Host's roster on reopening. Native keyboard, large-text, screen-reader and gesture acceptance follows the matrix below. - One list of PersonaBot DMs and Channels together, grouped by the Host's **Channel sections** as on the desktop sidebar: diff --git a/apps/mobile/src/ui/chat/conversation-row.tsx b/apps/mobile/src/ui/chat/conversation-row.tsx index 10c55c1be0..fb69957fde 100644 --- a/apps/mobile/src/ui/chat/conversation-row.tsx +++ b/apps/mobile/src/ui/chat/conversation-row.tsx @@ -1,11 +1,11 @@ import type { ReactNode, Ref } from 'react'; import type { Href } from 'expo-router'; import { SourceName } from './source-name'; -import { Pressable, View, type ViewInstance } from 'react-native'; +import { Platform, Pressable, View, type ViewInstance } from 'react-native'; import { BotAvatar } from '../bot-avatar'; import type { HostLook } from '../design/host-look'; -import { edge, radius, space } from '../design/tokens'; +import { chatList, edge, radius, space } from '../design/tokens'; import { useDesignTheme } from '../design/theme'; import { CountBadge } from '../primitives/count-badge'; import { Text } from '../primitives/text'; @@ -24,14 +24,13 @@ import { import { RowStatusGlyph } from './row-status-glyph'; import type { ConversationAction, ConversationActionKey } from './conversation-actions'; import { ConversationSwipe } from './conversation-swipe'; -import { ConversationPeek } from '../shell/conversation-peek'; -import { ActionMenuButton, type MenuAction } from '../primitives/action-menu-button'; +import { ConversationPeek, type ConversationPeekAction } from '../shell/conversation-peek'; import { showActionMenu } from '../primitives/app-dialog'; const AVATAR = 48; const RING = 6; -export const AVATAR_EDGE = space.x5 + RING / 2; +export const AVATAR_EDGE = chatList.gutter; export function ConversationRow({ row, @@ -65,17 +64,20 @@ export function ConversationRow({ const time = shortTime(row.at, now); const label = rowLabel(row, title, status, preview); const act = onAction ?? (() => undefined); - const theme = useDesignTheme(); - const menuActions: MenuAction[] = actions.map((action) => ({ + const availableActions = onAction ? actions : []; + const menuActions: ConversationPeekAction[] = availableActions.map((action) => ({ + key: action.key, label: action.label, icon: action.key === 'section' ? 'folder' : action.key === 'read' - ? 'check' + ? 'checkmark' : action.key === 'pin' || action.key === 'unpin' ? 'pin' - : 'mute', + : action.key === 'mute' + ? 'bell.slash' + : 'bell', run: () => act(action.key), })); const frame = ( @@ -93,6 +95,18 @@ export function ConversationRow({ unread={row.unread > 0 && !row.muted} badge={badge} onPress={previewHref ? undefined : onPress} + onLongPress={ + Platform.OS === 'android' && availableActions.length > 0 + ? () => + showActionMenu( + title, + availableActions.map((action) => ({ + text: action.label, + onPress: () => act(action.key), + })), + ) + : undefined + } avatar={ row.kind === 'bot' ? ( @@ -106,29 +120,13 @@ export function ConversationRow({ ); return ( - - - {previewHref ? {frame} : frame} - - {onAction && actions.length > 0 ? ( - - showActionMenu( - title, - actions.map((action) => ({ text: action.label, onPress: () => act(action.key) })), - ) - } - /> - ) : null} - + {previewHref ? ( + + {frame} + + ) : ( + frame + )} ); } @@ -167,6 +165,7 @@ export function ConversationRowFrame({ badge, avatar, onPress, + onLongPress, disabled = false, actions = [], onAction, @@ -188,6 +187,7 @@ export function ConversationRowFrame({ badge?: ReturnType; avatar: ReactNode; onPress?: (() => void) | undefined; + onLongPress?: (() => void) | undefined; disabled?: boolean; testID?: string; }) { @@ -197,6 +197,7 @@ export function ConversationRowFrame({ ref={ref} testID={testID} onPress={onPress} + onLongPress={onLongPress} disabled={disabled || onPress === undefined} accessibilityRole={onPress === undefined ? undefined : 'button'} accessibilityLabel={label} @@ -210,7 +211,8 @@ export function ConversationRowFrame({ alignItems: 'center', gap: space.x4, minHeight: theme.touch, - paddingHorizontal: space.x5, + paddingLeft: chatList.gutter - RING / 2, + paddingRight: chatList.gutter, paddingVertical: space.x3, backgroundColor: pressed ? theme.layer.canvas.pressed : theme.layer.canvas.fill, })} diff --git a/apps/mobile/src/ui/chat/conversation-swipe-actions.tsx b/apps/mobile/src/ui/chat/conversation-swipe-actions.tsx index 954d3ec251..f035a25442 100644 --- a/apps/mobile/src/ui/chat/conversation-swipe-actions.tsx +++ b/apps/mobile/src/ui/chat/conversation-swipe-actions.tsx @@ -5,7 +5,7 @@ import ReanimatedSwipeable, { } from 'react-native-gesture-handler/ReanimatedSwipeable'; import { pressTint, useDesignTheme } from '../design/theme'; -import { radius, space, swipeAction } from '../design/tokens'; +import { chatList, radius, space, swipeAction } from '../design/tokens'; import { ActivityIcon, CheckIcon, FolderIcon, MuteIcon, PinIcon } from '../shell/icons'; import { EDGE_WIDTH } from '../shell/host-drawer-model'; import type { ConversationAction, ConversationActionKey } from './conversation-actions'; @@ -51,7 +51,7 @@ export function ConversationSwipe({ flexDirection: 'row', alignItems: 'center', gap: space.x3, - paddingHorizontal: space.x5, + paddingHorizontal: chatList.gutter, }} > {actions diff --git a/apps/mobile/src/ui/design/tokens.ts b/apps/mobile/src/ui/design/tokens.ts index 02252a5c91..2be568ca26 100644 --- a/apps/mobile/src/ui/design/tokens.ts +++ b/apps/mobile/src/ui/design/tokens.ts @@ -66,6 +66,7 @@ export const fab = { export const materialDrawer = { width: 360, scrimGutter: 56, addressMinScale: 0.6 } as const; export const loadingIndicator = { size: 48, control: 20, controlStroke: 2.5 } as const; export const swipeAction = { size: 56 } as const; +export const chatList = { gutter: space.x5 } as const; export const environmentTab = { widthRatio: 0.6 } as const; export const tabLabel = { minimumFontScale: 0.6 } as const; export const modalRail = { maxWidth: 360 } as const; diff --git a/apps/mobile/src/ui/shell/conversation-peek.tsx b/apps/mobile/src/ui/shell/conversation-peek.tsx index 9fb424f90f..879dfa3df1 100644 --- a/apps/mobile/src/ui/shell/conversation-peek.tsx +++ b/apps/mobile/src/ui/shell/conversation-peek.tsx @@ -1,14 +1,38 @@ -import { Link, type Href } from 'expo-router'; +import { Link, type Href, type LinkMenuActionProps } from 'expo-router'; import type { ReactElement } from 'react'; import { Platform } from 'react-native'; export const conversationPeek = Platform.OS === 'ios'; -export function ConversationPeek({ href, children }: { href: Href; children: ReactElement }) { +export type ConversationPeekAction = { + key: string; + label: string; + icon: NonNullable; + run(): void; +}; + +export function ConversationPeek({ + href, + children, + actions = [], +}: { + href: Href; + children: ReactElement; + actions?: readonly ConversationPeekAction[]; +}) { return ( {children} + {actions.length > 0 ? ( + + {actions.map((action) => ( + + {action.label} + + ))} + + ) : null} ); } diff --git a/apps/mobile/src/ui/shell/home-header.tsx b/apps/mobile/src/ui/shell/home-header.tsx index a84d0bcbea..71ec1337c9 100644 --- a/apps/mobile/src/ui/shell/home-header.tsx +++ b/apps/mobile/src/ui/shell/home-header.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { ConversationEnvironment } from '../../host/conversation-environments'; import { hostColorInk } from '../design/host-look'; import { useDesignTheme } from '../design/theme'; -import { space } from '../design/tokens'; +import { chatList, space } from '../design/tokens'; import { Text } from '../primitives/text'; import { useMounted } from '../mounted'; import { t } from '../strings'; @@ -41,7 +41,7 @@ export function HomeHeader({ flexDirection: 'row', alignItems: 'center', gap: space.x2, - paddingHorizontal: space.x4, + paddingHorizontal: chatList.gutter, paddingTop: insets.top + space.x2, paddingBottom: space.x2, }} diff --git a/apps/mobile/test/home-search.test.tsx b/apps/mobile/test/home-search.test.tsx index fd6c27c7f6..e861ae4eb4 100644 --- a/apps/mobile/test/home-search.test.tsx +++ b/apps/mobile/test/home-search.test.tsx @@ -16,35 +16,45 @@ import { DesignThemeProvider, resolveTheme, useDesignTheme } from '../src/ui/des import type { RosterView } from '../src/host/client'; import { EDGE_WIDTH } from '../src/ui/shell/host-drawer-model'; -const { push, cloudState, directoryState, hostState, call, refresh, menus, swipeConfigs } = - vi.hoisted(() => ({ - push: vi.fn(), - call: vi.fn(async () => ({})), - refresh: vi.fn(async () => undefined), - menus: [] as Array<{ text: string; onPress?: () => unknown }[]>, - swipeConfigs: [] as Array>, - hostState: { - connecting: false, - removed: false, - color: undefined as HostColorId | undefined, - fontScale: 1, - actions: false, - unread: false, - }, - cloudState: { - error: undefined as string | undefined, - ready: true, - busy: false, - rows: [] as CloudRow[], - }, - directoryState: { - busy: false, - ready: true, - rows: [] as CloudConversation[], - workspaces: [] as ConversationWorkspace[], - errors: [] as string[], - }, - })); +const { + push, + cloudState, + directoryState, + hostState, + call, + refresh, + menus, + swipeConfigs, + rowProps, +} = vi.hoisted(() => ({ + push: vi.fn(), + call: vi.fn(async () => ({})), + refresh: vi.fn(async () => undefined), + menus: [] as Array<{ text: string; onPress?: () => unknown }[]>, + swipeConfigs: [] as Array>, + rowProps: new Map>(), + hostState: { + connecting: false, + removed: false, + color: undefined as HostColorId | undefined, + fontScale: 1, + actions: false, + unread: false, + }, + cloudState: { + error: undefined as string | undefined, + ready: true, + busy: false, + rows: [] as CloudRow[], + }, + directoryState: { + busy: false, + ready: true, + rows: [] as CloudConversation[], + workspaces: [] as ConversationWorkspace[], + errors: [] as string[], + }, +})); vi.mock('react-native-gesture-handler/ReanimatedSwipeable', async () => { const { useImperativeHandle, useState } = await import('react'); return { @@ -86,15 +96,14 @@ vi.mock('../src/ui/primitives/app-dialog', () => ({ menus.push(actions), showAppDialog: vi.fn(), })); -vi.mock('../src/ui/shell/conversation-peek', async () => { +vi.mock('../src/ui/shell/conversation-peek', async (importOriginal) => { + const actual = await importOriginal(); const { Platform } = await import('react-native'); return { + ...actual, get conversationPeek() { return Platform.OS === 'ios'; }, - ConversationPeek: ({ children }: { children: ReactNode }) => ( -
{children}
- ), }; }); const roster: RosterView = { @@ -152,6 +161,11 @@ vi.mock('react-native', async () => { const native = await import('./react-native-shim'); return { ...native, + Pressable: (props: Parameters[0]) => { + const id = props.testID; + if (typeof id === 'string' && id.startsWith('conversation-row-')) rowProps.set(id, props); + return native.Pressable(props); + }, useWindowDimensions: () => ({ ...native.useWindowDimensions(), fontScale: hostState.fontScale, @@ -173,7 +187,19 @@ vi.mock('react-native-safe-area-context', () => ({ vi.mock('expo-router', () => { const Stack = () => null; Stack.Screen = () => null; + const Link = ({ children }: { children: ReactNode }) =>
{children}
; + Link.Trigger = ({ children }: { children: ReactNode }) => children; + Link.Preview = () =>
; + Link.Menu = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + Link.MenuAction = ({ children, onPress }: { children: ReactNode; onPress(): void }) => ( + + ); return { + Link, Redirect: () => null, Stack, router: { push }, @@ -256,6 +282,7 @@ afterEach(() => { refresh.mockClear(); menus.length = 0; swipeConfigs.length = 0; + rowProps.clear(); Platform.OS = 'web'; conversationFilter.set('all'); cloudState.rows = []; @@ -273,6 +300,22 @@ async function home(): Promise { return host; } +async function rowMenuAction(view: HTMLElement, key: keyof typeof t.rowActions) { + if (Platform.OS === 'ios') { + const row = view.querySelector('[data-testid="conversation-row-bot:ada"]')!; + const menu = row.parentElement!.querySelector('[data-testid="native-context-menu"]')!; + const action = [...menu.querySelectorAll('button')].find( + (button) => button.textContent === t.rowActions[key], + )!; + await act(async () => action.click()); + } else { + await act(async () => (rowProps.get('conversation-row-bot:ada')!.onLongPress as () => void)()); + await act(async () => + menus.at(-1)!.find((action) => action.text === t.rowActions[key])!.onPress!(), + ); + } +} + async function type(view: HTMLElement, text: string): Promise { const open = view.querySelector('[data-testid="search-button"]'); if (open) await act(async () => open.click()); @@ -607,29 +650,72 @@ describe('Selected current Host accent', () => { }); describe('Chats row actions share the Host path', () => { - it('keeps mark-read in the menu while revealing only the three requested actions', async () => { - hostState.actions = true; - hostState.unread = true; - roster.bots[0]!.latestMessageId = 'message-1'; - const view = await home(); - await act(async () => - view.querySelector('[aria-label="Swipe row"]')!.click(), - ); - expect(view.querySelectorAll('[data-testid^="conversation-swipe-"]')).toHaveLength(3); - expect(view.querySelector('[data-testid="conversation-swipe-read"]')).toBeNull(); - await act(async () => - view - .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! - .click(), - ); - await act(async () => - menus.at(-1)!.find((action) => action.text === t.rowActions.read)!.onPress!(), - ); - expect(call).toHaveBeenLastCalledWith('channelMarkRead', { - channelId: 'dm-ada', - messageId: 'message-1', - }); - }); + it.each(['android', 'ios'] as const)( + 'removes overflow while keeping complete accessible actions on %s', + async (platform) => { + Platform.OS = platform; + hostState.actions = true; + hostState.unread = true; + roster.bots[0]!.latestMessageId = 'message-1'; + const view = await home(); + expect(view.querySelector('[data-testid="action-menu-trigger"]')).toBeNull(); + const props = rowProps.get('conversation-row-bot:ada')!; + expect(typeof props.onLongPress).toBe(platform === 'android' ? 'function' : 'undefined'); + expect(props.accessibilityActions).toEqual( + ['pin', 'section', 'mute', 'read'].map((key) => ({ + name: key, + label: t.rowActions[key as keyof typeof t.rowActions], + })), + ); + const invoke = props.onAccessibilityAction as (event: { + nativeEvent: { actionName: string }; + }) => void; + for (const key of ['pin', 'section', 'mute', 'read'] as const) { + call.mockClear(); + push.mockClear(); + await rowMenuAction(view, key); + const fromMenu = [...call.mock.calls]; + const navigation = [...push.mock.calls]; + call.mockClear(); + push.mockClear(); + await act(async () => invoke({ nativeEvent: { actionName: key } })); + expect(call.mock.calls).toEqual(fromMenu); + expect(push.mock.calls).toEqual(navigation); + } + call.mockClear(); + await act(async () => invoke({ nativeEvent: { actionName: 'unknown' } })); + expect(call).not.toHaveBeenCalled(); + expect(rowProps.get('conversation-row-bot:xiaoming')!.onLongPress).toBeUndefined(); + expect(rowProps.get('conversation-row-bot:xiaoming')!.accessibilityActions).toEqual([]); + if (platform === 'ios') { + const preview = view.querySelector( + '[data-testid="conversation-row-bot:ada"]', + )!.parentElement!; + expect(preview.querySelector('[data-testid="native-preview"]')).not.toBeNull(); + expect(preview.querySelectorAll('[data-menu-title]')).toHaveLength(4); + } + }, + ); + it.each(['android', 'ios'] as const)( + 'keeps mark-read in the %s menu with three swipe actions', + async (platform) => { + Platform.OS = platform; + hostState.actions = true; + hostState.unread = true; + roster.bots[0]!.latestMessageId = 'message-1'; + const view = await home(); + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + expect(view.querySelectorAll('[data-testid^="conversation-swipe-"]')).toHaveLength(3); + expect(view.querySelector('[data-testid="conversation-swipe-read"]')).toBeNull(); + await rowMenuAction(view, 'read'); + expect(call).toHaveBeenLastCalledWith('channelMarkRead', { + channelId: 'dm-ada', + messageId: 'message-1', + }); + }, + ); it.each(['ios', 'android'] as const)( 'runs pin and mute from swipe and menu on %s', async (platform) => { @@ -646,14 +732,7 @@ describe('Chats row actions share the Host path', () => { .click(), ); const swipeCall = call.mock.calls.at(-1); - await act(async () => - view - .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! - .click(), - ); - await act(async () => - menus.at(-1)!.find((action) => action.text === t.rowActions[key])!.onPress!(), - ); + await rowMenuAction(view, key); expect(call.mock.calls.at(-1)).toEqual(swipeCall); expect(swipeCall).toEqual( key === 'pin' @@ -671,63 +750,61 @@ describe('Chats row actions share the Host path', () => { }, ); - it('opens the same Host-bound section chooser from the menu and swipe', async () => { - hostState.actions = true; - const view = await home(); - await act(async () => - view.querySelector('[aria-label="Swipe row"]')!.click(), - ); - await act(async () => - view.querySelector('[data-testid="conversation-swipe-section"]')!.click(), - ); - expect(push).toHaveBeenLastCalledWith({ - pathname: '/conversation-section', - params: { hostId: 'h1', channelId: 'dm-ada' }, - }); - await act(async () => - view - .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! - .click(), - ); - await act(async () => - menus.at(-1)!.find((action) => action.text === t.rowActions.section)!.onPress!(), - ); - expect(push.mock.calls[1]).toEqual(push.mock.calls[0]); - expect(call).not.toHaveBeenCalled(); - }); - - it('uses the same inverse operations for pinned and muted conversations', async () => { - hostState.actions = true; - roster.arrangement = { - pins: ['dm-ada'], - muted: ['dm-ada'], - hidden: [], - sections: [], - sectionOrder: [], - }; - const view = await home(); - for (const key of ['unpin', 'unmute'] as const) { + it.each(['android', 'ios'] as const)( + 'opens the same Host-bound section chooser from the %s menu and swipe', + async (platform) => { + Platform.OS = platform; + hostState.actions = true; + const view = await home(); await act(async () => view.querySelector('[aria-label="Swipe row"]')!.click(), ); - await act(async () => - view.querySelector(`[data-testid="conversation-swipe-${key}"]`)!.click(), - ); - const fromSwipe = call.mock.calls.at(-1); await act(async () => view - .querySelector(`[aria-label="${t.conversationActions('Ada')}"]`)! + .querySelector('[data-testid="conversation-swipe-section"]')! .click(), ); - await act(async () => - menus.at(-1)!.find((action) => action.text === t.rowActions[key])!.onPress!(), - ); - expect(call.mock.calls.at(-1)).toEqual(fromSwipe); - expect(fromSwipe).toEqual( - key === 'unpin' - ? ['rosterBatch', { action: 'unpin', channelIds: ['dm-ada'] }] - : ['channelMuteSet', { channelId: 'dm-ada', muted: false }], - ); - } - }); + expect(push).toHaveBeenLastCalledWith({ + pathname: '/conversation-section', + params: { hostId: 'h1', channelId: 'dm-ada' }, + }); + await rowMenuAction(view, 'section'); + expect(push.mock.calls[1]).toEqual(push.mock.calls[0]); + expect(call).not.toHaveBeenCalled(); + }, + ); + + it.each(['android', 'ios'] as const)( + 'uses the same inverse operations on %s', + async (platform) => { + Platform.OS = platform; + hostState.actions = true; + roster.arrangement = { + pins: ['dm-ada'], + muted: ['dm-ada'], + hidden: [], + sections: [], + sectionOrder: [], + }; + const view = await home(); + for (const key of ['unpin', 'unmute'] as const) { + await act(async () => + view.querySelector('[aria-label="Swipe row"]')!.click(), + ); + await act(async () => + view + .querySelector(`[data-testid="conversation-swipe-${key}"]`)! + .click(), + ); + const fromSwipe = call.mock.calls.at(-1); + await rowMenuAction(view, key); + expect(call.mock.calls.at(-1)).toEqual(fromSwipe); + expect(fromSwipe).toEqual( + key === 'unpin' + ? ['rosterBatch', { action: 'unpin', channelIds: ['dm-ada'] }] + : ['channelMuteSet', { channelId: 'dm-ada', muted: false }], + ); + } + }, + ); }); diff --git a/changes/2588-chat-row-context-actions.md b/changes/2588-chat-row-context-actions.md new file mode 100644 index 0000000000..6518c17629 --- /dev/null +++ b/changes/2588-chat-row-context-actions.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- Chats rows give more room to conversation details by removing the persistent overflow button. Swipe for Pin, Add to section and Mute; long press for the full menu, including Mark as read, with the existing preview on iOS. Screen-reader actions remain available, and the header, avatars and swipe rail share a horizontal gutter ([#2588](https://github.com/BotHarness/DeepSeekBot/issues/2588)). + +## zh + +- 聊天行移除常驻更多按钮,为会话内容留出空间。滑动可置顶、添加到分区和静音;长按打开包含标为已读的完整菜单,iOS 保留原有预览。屏幕阅读器操作继续可用,标题栏、头像和滑动操作栏采用统一水平留白([#2588](https://github.com/BotHarness/DeepSeekBot/issues/2588))。