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
197 changes: 97 additions & 100 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
resolveThreadListV2Status,
resolveThreadListV2ProviderDrivers,
resolveThreadListV2SwipeActions,
layoutThreadListV2RowMenuActions,
type ThreadListV2Status,
} from "./threadListV2";
import { QueuedMessageIcon } from "./queued-message-icon";
Expand Down Expand Up @@ -830,9 +831,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
],
[snoozePresets],
);
const arrangementMenuItems = useMemo<MenuAction[]>(
() => [
...(props.reorderSupported === true
const reorderMenuItems = useMemo<MenuAction[]>(
() =>
props.reorderSupported === true
? [
{ id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" },
{
Expand All @@ -848,22 +849,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
attributes: { disabled: props.canMoveDown !== true },
} satisfies MenuAction,
]
: []),
...(props.pinningSupported
: [],
[props.canMoveDown, props.canMoveUp, props.reorderSupported],
);
const pinMenuItems = useMemo<MenuAction[]>(
() =>
props.pinningSupported
? [
thread.pinnedAt != null
? { id: "unpin", title: "Unpin", image: "pin.slash" }
: { id: "pin", title: "Pin", image: "pin" },
]
: []),
],
[
props.canMoveDown,
props.canMoveUp,
props.reorderSupported,
props.pinningSupported,
thread.pinnedAt,
],
: [],
[props.pinningSupported, thread.pinnedAt],
);
const autoSettleMenuItems = useMemo<MenuAction[]>(
() =>
Expand Down Expand Up @@ -911,66 +909,90 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
],
[canTurnIntoProject, props.titleRegenerationSupported, thread.runtime, isRegeneratingTitle],
);
const snoozableCardMenuActions = useMemo<MenuAction[]>(
() => [
{ id: "settle", title: "Settle", image: "checkmark" },
{
id: "snooze",
title: "Snooze",
image: "clock",
subactions: snoozePresetActions,
},
...arrangementMenuItems,
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
],
[arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems],
);
const cardMenuActions = useMemo<MenuAction[]>(
() => [
CARD_MENU_ACTIONS[0]!,
...arrangementMenuItems,
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
...CARD_MENU_ACTIONS.slice(1),
],
[arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
);
const slimMenuActions = useMemo<MenuAction[]>(
() => [
SLIM_MENU_ACTIONS[0]!,
...arrangementMenuItems.filter(
(action) => action.id !== "move-up" && action.id !== "move-down",
),
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
SLIM_MENU_ACTIONS[1]!,
],
[arrangementMenuItems, autoSettleMenuItems, titleMenuItems],
);
const snoozedMenuActions = useMemo<MenuAction[]>(
() => [
SNOOZED_MENU_ACTIONS[0]!,
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
SNOOZED_MENU_ACTIONS[1]!,
],
[autoSettleMenuItems, titleMenuItems],
);
const legacyMenuActions = useMemo<MenuAction[]>(
() => [
LEGACY_MENU_ACTIONS[0]!,
...arrangementMenuItems,
...titleMenuItems,
LEGACY_MENU_ACTIONS[1]!,
],
[arrangementMenuItems, titleMenuItems],
);
const menuActions = useMemo<MenuAction[]>(() => {
const nested = Platform.OS === "ios";
const direct: MenuAction[] = [
...(thread.branch
? [
{
id: "new-thread-on-branch",
title: getThreadListV2NewBranchMenuTitle(thread.branch),
image: "square.and.pencil",
},
]
: []),
{ id: "copy-thread", title: "Copy thread", image: "text.bubble" },
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
];
if (snoozedRow) {
return layoutThreadListV2RowMenuActions({
nested,
direct: [...direct, SNOOZED_MENU_ACTIONS[0]!],
reorder: [],
pin: [],
secondary: [
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
SNOOZED_MENU_ACTIONS[1]!,
],
});
}
if (!props.settlementSupported) {
return layoutThreadListV2RowMenuActions({
nested,
direct: [...direct, LEGACY_MENU_ACTIONS[0]!],
reorder: reorderMenuItems,
pin: pinMenuItems,
secondary: [...titleMenuItems, LEGACY_MENU_ACTIONS[1]!],
});
}
if (canUnsettle) {
return layoutThreadListV2RowMenuActions({
nested,
direct: [...direct, SLIM_MENU_ACTIONS[0]!],
reorder: reorderMenuItems.filter(
(action) => action.id !== "move-up" && action.id !== "move-down",
),
pin: pinMenuItems,
secondary: [
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
SLIM_MENU_ACTIONS[1]!,
],
});
}
return layoutThreadListV2RowMenuActions({
nested,
direct: [
...direct,
CARD_MENU_ACTIONS[0]!,
...(swipeActions.secondary === "snooze"
? [{ id: "snooze", title: "Snooze", image: "clock", subactions: snoozePresetActions }]
: []),
],
reorder: reorderMenuItems,
pin: pinMenuItems,
secondary: [
...titleMenuItems,
...autoSettleMenuItems,
LEGACY_MENU_ACTIONS[0]!,
CARD_MENU_ACTIONS[1]!,
],
});
}, [
autoSettleMenuItems,
canUnsettle,
pinMenuItems,
props.settlementSupported,
reorderMenuItems,
snoozePresetActions,
snoozedRow,
swipeActions.secondary,
thread.branch,
titleMenuItems,
]);
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
Expand Down Expand Up @@ -1485,32 +1507,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
threadTitle={thread.title}
>
{(close) => (
<Menu
actions={[
...(thread.branch
? [
{
id: "new-thread-on-branch",
title: getThreadListV2NewBranchMenuTitle(thread.branch),
image: "square.and.pencil",
},
]
: []),
{ id: "copy-thread", title: "Copy thread", image: "text.bubble" },
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
...(snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions),
]}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
<Menu actions={menuActions} onPressAction={handleMenuAction} shouldOpenOnLongPress>
{rowContent(close)}
</Menu>
)}
Expand Down
49 changes: 49 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import { describe, expect, it, vi } from "vite-plus/test";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
Expand All @@ -34,6 +35,7 @@ import {
buildThreadListV2Items,
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
layoutThreadListV2RowMenuActions,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozePresets,
resolveThreadListV2Status,
Expand Down Expand Up @@ -99,6 +101,53 @@ describe("resolveThreadListV2SnoozePresets", () => {
});
});

describe("layoutThreadListV2RowMenuActions", () => {
const actions = (...ids: string[]): MenuAction[] => ids.map((id) => ({ id, title: id }));
const leafIds = (items: readonly MenuAction[]): string[] =>
items.flatMap((item) => (item.subactions ? leafIds(item.subactions) : [item.id!]));

it("keeps every action once while nesting iOS secondary actions under More", () => {
const full = {
direct: actions("new-thread-on-branch", "copy-thread", "copy-thread-id", "settle", "snooze"),
reorder: actions("arrange", "move-up", "move-down"),
pin: actions("unpin"),
secondary: actions("rename", "regenerate-title", "turn-into-project", "archive", "delete"),
};
const flat = layoutThreadListV2RowMenuActions({ ...full, nested: false });
const nested = layoutThreadListV2RowMenuActions({ ...full, nested: true });

expect(flat.map((action) => action.id)).toEqual(
[...full.direct, ...full.reorder, ...full.pin, ...full.secondary].map((action) => action.id),
);
expect(nested.map((action) => action.id)).toEqual([
"new-thread-on-branch",
"copy-thread",
"copy-thread-id",
"settle",
"snooze",
"unpin",
"more",
]);
expect(nested.at(-1)?.subactions?.map((action) => action.id)).toEqual([
"arrange-menu",
"rename",
"regenerate-title",
"turn-into-project",
"archive",
"delete",
]);
expect(leafIds(nested).toSorted()).toEqual(leafIds(flat).toSorted());

// Slim rows only offer "Arrange threads…", which needs no submenu of its own.
const slim = layoutThreadListV2RowMenuActions({
...full,
reorder: actions("arrange"),
nested: true,
});
expect(slim.at(-1)?.subactions?.[0]?.id).toBe("arrange");
});
});

describe("resolveThreadListV2Status", () => {
it("presents decoded thread waits as Waiting while preserving requests and older servers", () => {
const raw = makeRawThreadShell({
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts";
import type { SnoozePresetSetting } from "@t3tools/contracts/settings";
import type { MenuAction } from "@react-native-menu/menu";

import type { ThreadListProvider } from "../../state/thread-list-environments";
import type { ThreadMoveAvailability } from "./threadOrder";
Expand Down Expand Up @@ -134,6 +135,42 @@ export function resolveThreadListV2SwipeActions(input: {
};
}

// Nested keeps iOS menus short: UIKit scrolls a taller menu, and that scroll
// cancels press-drag-release selection.
export function layoutThreadListV2RowMenuActions(input: {
readonly nested: boolean;
readonly direct: readonly MenuAction[];
readonly reorder: readonly MenuAction[];
readonly pin: readonly MenuAction[];
readonly secondary: readonly MenuAction[];
}): MenuAction[] {
if (!input.nested) {
return [...input.direct, ...input.reorder, ...input.pin, ...input.secondary];
}
return [
...input.direct,
...input.pin,
{
id: "more",
title: "More",
image: "ellipsis.circle",
subactions: [
...(input.reorder.length > 1
? [
{
id: "arrange-menu",
title: "Arrange",
image: "arrow.up.arrow.down",
subactions: [...input.reorder],
},
]
: input.reorder),
...input.secondary,
],
},
];
}

export function resolveThreadListV2SnoozeGateExpiryMs(
thread: Pick<
EnvironmentThreadShell,
Expand Down
Loading