Skip to content

Commit cb2bc9c

Browse files
committed
fix(mobile): keep iOS thread menus short enough for drag selection
1 parent 9e85538 commit cb2bc9c

3 files changed

Lines changed: 183 additions & 100 deletions

File tree

‎apps/mobile/src/features/threads/thread-list-v2-items.tsx‎

Lines changed: 97 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
resolveThreadListV2Status,
7474
resolveThreadListV2ProviderDrivers,
7575
resolveThreadListV2SwipeActions,
76+
layoutThreadListV2RowMenuActions,
7677
type ThreadListV2Status,
7778
} from "./threadListV2";
7879
import { QueuedMessageIcon } from "./queued-message-icon";
@@ -830,9 +831,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
830831
],
831832
[snoozePresets],
832833
);
833-
const arrangementMenuItems = useMemo<MenuAction[]>(
834-
() => [
835-
...(props.reorderSupported === true
834+
const reorderMenuItems = useMemo<MenuAction[]>(
835+
() =>
836+
props.reorderSupported === true
836837
? [
837838
{ id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" },
838839
{
@@ -848,22 +849,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
848849
attributes: { disabled: props.canMoveDown !== true },
849850
} satisfies MenuAction,
850851
]
851-
: []),
852-
...(props.pinningSupported
852+
: [],
853+
[props.canMoveDown, props.canMoveUp, props.reorderSupported],
854+
);
855+
const pinMenuItems = useMemo<MenuAction[]>(
856+
() =>
857+
props.pinningSupported
853858
? [
854859
thread.pinnedAt != null
855860
? { id: "unpin", title: "Unpin", image: "pin.slash" }
856861
: { id: "pin", title: "Pin", image: "pin" },
857862
]
858-
: []),
859-
],
860-
[
861-
props.canMoveDown,
862-
props.canMoveUp,
863-
props.reorderSupported,
864-
props.pinningSupported,
865-
thread.pinnedAt,
866-
],
863+
: [],
864+
[props.pinningSupported, thread.pinnedAt],
867865
);
868866
const autoSettleMenuItems = useMemo<MenuAction[]>(
869867
() =>
@@ -911,66 +909,90 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
911909
],
912910
[canTurnIntoProject, props.titleRegenerationSupported, thread.runtime, isRegeneratingTitle],
913911
);
914-
const snoozableCardMenuActions = useMemo<MenuAction[]>(
915-
() => [
916-
{ id: "settle", title: "Settle", image: "checkmark" },
917-
{
918-
id: "snooze",
919-
title: "Snooze",
920-
image: "clock",
921-
subactions: snoozePresetActions,
922-
},
923-
...arrangementMenuItems,
924-
...titleMenuItems,
925-
...autoSettleMenuItems,
926-
LEGACY_MENU_ACTIONS[0]!,
927-
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
928-
],
929-
[arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems],
930-
);
931-
const cardMenuActions = useMemo<MenuAction[]>(
932-
() => [
933-
CARD_MENU_ACTIONS[0]!,
934-
...arrangementMenuItems,
935-
...titleMenuItems,
936-
...autoSettleMenuItems,
937-
LEGACY_MENU_ACTIONS[0]!,
938-
...CARD_MENU_ACTIONS.slice(1),
939-
],
940-
[arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
941-
);
942-
const slimMenuActions = useMemo<MenuAction[]>(
943-
() => [
944-
SLIM_MENU_ACTIONS[0]!,
945-
...arrangementMenuItems.filter(
946-
(action) => action.id !== "move-up" && action.id !== "move-down",
947-
),
948-
...titleMenuItems,
949-
...autoSettleMenuItems,
950-
LEGACY_MENU_ACTIONS[0]!,
951-
SLIM_MENU_ACTIONS[1]!,
952-
],
953-
[arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
954-
);
955-
const snoozedMenuActions = useMemo<MenuAction[]>(
956-
() => [
957-
SNOOZED_MENU_ACTIONS[0]!,
958-
...titleMenuItems,
959-
...autoSettleMenuItems,
960-
LEGACY_MENU_ACTIONS[0]!,
961-
SNOOZED_MENU_ACTIONS[1]!,
962-
],
963-
[autoSettleMenuItems, titleMenuItems],
964-
);
965-
const legacyMenuActions = useMemo<MenuAction[]>(
966-
() => [
967-
LEGACY_MENU_ACTIONS[0]!,
968-
...arrangementMenuItems,
969-
...titleMenuItems,
970-
LEGACY_MENU_ACTIONS[1]!,
971-
],
972-
[arrangementMenuItems, titleMenuItems],
973-
);
912+
const menuActions = useMemo<MenuAction[]>(() => {
913+
const nested = Platform.OS === "ios";
914+
const direct: MenuAction[] = [
915+
...(thread.branch
916+
? [
917+
{
918+
id: "new-thread-on-branch",
919+
title: getThreadListV2NewBranchMenuTitle(thread.branch),
920+
image: "square.and.pencil",
921+
},
922+
]
923+
: []),
924+
{ id: "copy-thread", title: "Copy thread", image: "text.bubble" },
925+
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
926+
];
927+
if (snoozedRow) {
928+
return layoutThreadListV2RowMenuActions({
929+
nested,
930+
direct: [...direct, SNOOZED_MENU_ACTIONS[0]!],
931+
reorder: [],
932+
pin: [],
933+
secondary: [
934+
...titleMenuItems,
935+
...autoSettleMenuItems,
936+
LEGACY_MENU_ACTIONS[0]!,
937+
SNOOZED_MENU_ACTIONS[1]!,
938+
],
939+
});
940+
}
941+
if (!props.settlementSupported) {
942+
return layoutThreadListV2RowMenuActions({
943+
nested,
944+
direct: [...direct, LEGACY_MENU_ACTIONS[0]!],
945+
reorder: reorderMenuItems,
946+
pin: pinMenuItems,
947+
secondary: [...titleMenuItems, LEGACY_MENU_ACTIONS[1]!],
948+
});
949+
}
950+
if (canUnsettle) {
951+
return layoutThreadListV2RowMenuActions({
952+
nested,
953+
direct: [...direct, SLIM_MENU_ACTIONS[0]!],
954+
reorder: reorderMenuItems.filter(
955+
(action) => action.id !== "move-up" && action.id !== "move-down",
956+
),
957+
pin: pinMenuItems,
958+
secondary: [
959+
...titleMenuItems,
960+
...autoSettleMenuItems,
961+
LEGACY_MENU_ACTIONS[0]!,
962+
SLIM_MENU_ACTIONS[1]!,
963+
],
964+
});
965+
}
966+
return layoutThreadListV2RowMenuActions({
967+
nested,
968+
direct: [
969+
...direct,
970+
CARD_MENU_ACTIONS[0]!,
971+
...(swipeActions.secondary === "snooze"
972+
? [{ id: "snooze", title: "Snooze", image: "clock", subactions: snoozePresetActions }]
973+
: []),
974+
],
975+
reorder: reorderMenuItems,
976+
pin: pinMenuItems,
977+
secondary: [
978+
...titleMenuItems,
979+
...autoSettleMenuItems,
980+
LEGACY_MENU_ACTIONS[0]!,
981+
CARD_MENU_ACTIONS[1]!,
982+
],
983+
});
984+
}, [
985+
autoSettleMenuItems,
986+
canUnsettle,
987+
pinMenuItems,
988+
props.settlementSupported,
989+
reorderMenuItems,
990+
snoozePresetActions,
991+
snoozedRow,
992+
swipeActions.secondary,
993+
thread.branch,
994+
titleMenuItems,
995+
]);
974996
const handleMenuAction = useCallback(
975997
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
976998
if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
@@ -1485,32 +1507,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
14851507
threadTitle={thread.title}
14861508
>
14871509
{(close) => (
1488-
<Menu
1489-
actions={[
1490-
...(thread.branch
1491-
? [
1492-
{
1493-
id: "new-thread-on-branch",
1494-
title: getThreadListV2NewBranchMenuTitle(thread.branch),
1495-
image: "square.and.pencil",
1496-
},
1497-
]
1498-
: []),
1499-
{ id: "copy-thread", title: "Copy thread", image: "text.bubble" },
1500-
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
1501-
...(snoozedRow
1502-
? snoozedMenuActions
1503-
: !props.settlementSupported
1504-
? legacyMenuActions
1505-
: canUnsettle
1506-
? slimMenuActions
1507-
: swipeActions.secondary === "snooze"
1508-
? snoozableCardMenuActions
1509-
: cardMenuActions),
1510-
]}
1511-
onPressAction={handleMenuAction}
1512-
shouldOpenOnLongPress
1513-
>
1510+
<Menu actions={menuActions} onPressAction={handleMenuAction} shouldOpenOnLongPress>
15141511
{rowContent(close)}
15151512
</Menu>
15161513
)}

‎apps/mobile/src/features/threads/threadListV2.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
RunId,
2626
ThreadId,
2727
} from "@t3tools/contracts";
28+
import type { MenuAction } from "@react-native-menu/menu";
2829
import { describe, expect, it, vi } from "vite-plus/test";
2930

3031
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
@@ -34,6 +35,7 @@ import {
3435
buildThreadListV2Items,
3536
buildThreadListV2ListItems,
3637
getThreadListV2OrderedSection,
38+
layoutThreadListV2RowMenuActions,
3739
resolveThreadListV2SnoozeMenuSelection,
3840
resolveThreadListV2SnoozePresets,
3941
resolveThreadListV2Status,
@@ -99,6 +101,53 @@ describe("resolveThreadListV2SnoozePresets", () => {
99101
});
100102
});
101103

104+
describe("layoutThreadListV2RowMenuActions", () => {
105+
const actions = (...ids: string[]): MenuAction[] => ids.map((id) => ({ id, title: id }));
106+
const leafIds = (items: readonly MenuAction[]): string[] =>
107+
items.flatMap((item) => (item.subactions ? leafIds(item.subactions) : [item.id!]));
108+
109+
it("keeps every action once while nesting iOS secondary actions under More", () => {
110+
const full = {
111+
direct: actions("new-thread-on-branch", "copy-thread", "copy-thread-id", "settle", "snooze"),
112+
reorder: actions("arrange", "move-up", "move-down"),
113+
pin: actions("unpin"),
114+
secondary: actions("rename", "regenerate-title", "turn-into-project", "archive", "delete"),
115+
};
116+
const flat = layoutThreadListV2RowMenuActions({ ...full, nested: false });
117+
const nested = layoutThreadListV2RowMenuActions({ ...full, nested: true });
118+
119+
expect(flat.map((action) => action.id)).toEqual(
120+
[...full.direct, ...full.reorder, ...full.pin, ...full.secondary].map((action) => action.id),
121+
);
122+
expect(nested.map((action) => action.id)).toEqual([
123+
"new-thread-on-branch",
124+
"copy-thread",
125+
"copy-thread-id",
126+
"settle",
127+
"snooze",
128+
"unpin",
129+
"more",
130+
]);
131+
expect(nested.at(-1)?.subactions?.map((action) => action.id)).toEqual([
132+
"arrange-menu",
133+
"rename",
134+
"regenerate-title",
135+
"turn-into-project",
136+
"archive",
137+
"delete",
138+
]);
139+
expect(leafIds(nested).toSorted()).toEqual(leafIds(flat).toSorted());
140+
141+
// Slim rows only offer "Arrange threads…", which needs no submenu of its own.
142+
const slim = layoutThreadListV2RowMenuActions({
143+
...full,
144+
reorder: actions("arrange"),
145+
nested: true,
146+
});
147+
expect(slim.at(-1)?.subactions?.[0]?.id).toBe("arrange");
148+
});
149+
});
150+
102151
describe("resolveThreadListV2Status", () => {
103152
it("presents decoded thread waits as Waiting while preserving requests and older servers", () => {
104153
const raw = makeRawThreadShell({

‎apps/mobile/src/features/threads/threadListV2.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "@t3tools/client-runtime/state/thread-sort";
3131
import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts";
3232
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
33+
import type { MenuAction } from "@react-native-menu/menu";
3334

3435
import type { ThreadListProvider } from "../../state/thread-list-environments";
3536
import type { ThreadMoveAvailability } from "./threadOrder";
@@ -134,6 +135,42 @@ export function resolveThreadListV2SwipeActions(input: {
134135
};
135136
}
136137

138+
// Nested keeps iOS menus short: UIKit scrolls a taller menu, and that scroll
139+
// cancels press-drag-release selection.
140+
export function layoutThreadListV2RowMenuActions(input: {
141+
readonly nested: boolean;
142+
readonly direct: readonly MenuAction[];
143+
readonly reorder: readonly MenuAction[];
144+
readonly pin: readonly MenuAction[];
145+
readonly secondary: readonly MenuAction[];
146+
}): MenuAction[] {
147+
if (!input.nested) {
148+
return [...input.direct, ...input.reorder, ...input.pin, ...input.secondary];
149+
}
150+
return [
151+
...input.direct,
152+
...input.pin,
153+
{
154+
id: "more",
155+
title: "More",
156+
image: "ellipsis.circle",
157+
subactions: [
158+
...(input.reorder.length > 1
159+
? [
160+
{
161+
id: "arrange-menu",
162+
title: "Arrange",
163+
image: "arrow.up.arrow.down",
164+
subactions: [...input.reorder],
165+
},
166+
]
167+
: input.reorder),
168+
...input.secondary,
169+
],
170+
},
171+
];
172+
}
173+
137174
export function resolveThreadListV2SnoozeGateExpiryMs(
138175
thread: Pick<
139176
EnvironmentThreadShell,

0 commit comments

Comments
 (0)