diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx
index e0936c57180a..4598bd128fc9 100644
--- a/apps/mobile/src/components/ControlPill.tsx
+++ b/apps/mobile/src/components/ControlPill.tsx
@@ -230,6 +230,11 @@ export function ControlPillMenu(
child.props.onPress?.(event);
},
});
+ menuProps.onDragBegin = () => {
+ menuPress.current.suppressPress = true;
+ pendingPress.current = null;
+ props.onDragBegin?.();
+ };
menuProps.onMenuInteractionStart = () => {
menuPress.current.isPreparing = true;
props.onMenuInteractionStart?.();
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 4e01ef6077da..afb03d428075 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -49,6 +49,7 @@ import {
ThreadListShowMoreRow,
} from "../threads/thread-list-items";
import {
+ ThreadListV2DropHeader,
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2SettledShelfHeader,
@@ -762,6 +763,7 @@ export function HomeScreen(props: HomeScreenProps) {
const threadListV2Items = useMemo(
() =>
buildThreadListV2ListItems({
+ arrangementTargets: Platform.OS === "ios",
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
snoozedCount: threadListV2Layout.snoozedCount,
@@ -781,6 +783,7 @@ export function HomeScreen(props: HomeScreenProps) {
const showTrailingDivider =
nextItem?.type === "v2-thread" ||
(nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
+ if (item.type === "v2-drop-header") return ;
if (item.type === "v2-pending") {
const pendingScopeKey = scopedProjectKey(
item.pendingTask.environmentId,
@@ -873,6 +876,7 @@ export function HomeScreen(props: HomeScreenProps) {
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
+ reorderBusy={pendingOrder !== null}
canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={handleSnoozeThread}
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index dce02cac1d4b..62993472976d 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -74,6 +74,7 @@ import {
ThreadListShowMoreRow,
} from "./thread-list-items";
import {
+ ThreadListV2DropHeader,
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2SettledShelfHeader,
@@ -594,6 +595,7 @@ function ThreadNavigationSidebarPane(
pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
);
const items: SidebarListItem[] = buildThreadListV2ListItems({
+ arrangementTargets: Platform.OS === "ios",
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
snoozedCount: threadListV2Layout.snoozedCount,
@@ -833,12 +835,14 @@ function ThreadNavigationSidebarPane(
previous.type === "v2-show-more" ||
previous.type === "v2-pending" ||
previous.type === "v2-snoozed-shelf" ||
+ previous.type === "v2-drop-header" ||
previous.type === "v2-settled-shelf" ||
item.type === "v2-thread" ||
item.type === "v2-show-more" ||
item.type === "v2-pending" ||
item.type === "v2-snoozed-shelf" ||
- item.type === "v2-settled-shelf"
+ item.type === "v2-settled-shelf" ||
+ item.type === "v2-drop-header"
) {
return false;
}
@@ -866,6 +870,8 @@ function ThreadNavigationSidebarPane(
const renderListItem = useCallback(
({ item }: { readonly item: SidebarListItem }) => {
switch (item.type) {
+ case "v2-drop-header":
+ return ;
case "v2-pending": {
const pendingScopeKey = scopedProjectKey(
item.pendingTask.environmentId,
@@ -941,6 +947,7 @@ function ThreadNavigationSidebarPane(
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
+ reorderBusy={pendingOrder !== null}
canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
onSnoozeThread={snoozeThread}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index a27ba03e4f41..d2f6a3cf3534 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -1,7 +1,13 @@
+import type { ReactNode } from "react";
+import { useThreadListActions } from "../home/useThreadListActions";
+import { useAtomValue } from "@effect/atom-react";
+import { threadDropBusyAtom } from "../../state/thread-order";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadArrangementOpenAtom } from "../../state/thread-order";
-import type { ThreadMoveDestination } from "./threadOrder";
+import { environmentThreadShells } from "../../state/threads";
+import { scopedThreadKey } from "../../lib/scopedEntities";
+import { threadDragAction, type ThreadMoveDestination } from "./threadOrder";
import type {
EnvironmentProject,
EnvironmentThreadShell,
@@ -158,33 +164,37 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS
readonly pane?: "screen" | "sidebar";
}) {
return (
- ({ opacity: pressed ? 0.6 : 1 })}
- >
-
- {props.expanded ? "Settled" : `Settled (${props.count})`}
-
-
-
-
+
+ ({ opacity: pressed ? 0.6 : 1 })}
+ >
+
+ {props.expanded ? "Settled" : `Settled (${props.count})`}
+
+
+
+
+
);
});
@@ -398,6 +408,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly titleRegenerationSupported: boolean;
/** Server supports reordering this card's section. */
readonly reorderSupported?: boolean;
+ readonly reorderBusy?: boolean;
readonly onMoveThread?: (
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
@@ -431,6 +442,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onUnpinThread,
onMoveThread,
} = props;
+ const dropBusy = useAtomValue(threadDropBusyAtom);
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;
@@ -1079,6 +1091,45 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
? snoozableCardMenuActions
: cardMenuActions),
]}
+ dragItemId={
+ Platform.OS === "ios" ? scopedThreadKey(thread.environmentId, thread.id) : ""
+ }
+ dragEnabled={props.reorderSupported === true && props.reorderBusy !== true && !dropBusy}
+ dropEnabled={
+ variant === "card" && !snoozedRow && props.reorderBusy !== true && !dropBusy
+ }
+ dragGroup="t3-thread-arrange"
+ dropSection={pinnedRow ? "pinned" : "active"}
+ dragActionLabels={JSON.stringify(
+ Object.fromEntries(
+ (["pinned", "active", "settled"] as const).flatMap((destination) => {
+ if (destination === "settled" && !props.settlementSupported) return [];
+ const action = threadDragAction(
+ pinnedRow
+ ? "pinned"
+ : snoozedRow
+ ? "snoozed"
+ : variant === "slim"
+ ? "settled"
+ : "active",
+ destination,
+ );
+ return action === null ? [] : [[destination, action]];
+ }),
+ ),
+ )}
+ onItemDrop={({ nativeEvent }) => {
+ if (nativeEvent.placement !== "before" && nativeEvent.placement !== "after") return;
+ const source = appAtomRegistry
+ .get(environmentThreadShells.threadShellsAtom)
+ .find((row) => scopedThreadKey(row.environmentId, row.id) === nativeEvent.itemId);
+ if (!source || snoozedRow || variant !== "card") return;
+ onMoveThread?.(source, {
+ targetId: scopedThreadKey(thread.environmentId, thread.id),
+ section: pinnedRow ? "pinned" : "active",
+ placement: nativeEvent.placement,
+ });
+ }}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
@@ -1089,3 +1140,43 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
);
});
+
+/** Always present on iOS so an empty section is still a reachable drop target. */
+export function ThreadListV2DropHeader(props: {
+ section: "pinned" | "active" | "settled";
+ children?: ReactNode;
+}) {
+ const { moveThread } = useThreadListActions();
+ const busy = useAtomValue(threadDropBusyAtom);
+ if (Platform.OS !== "ios") return props.children ?? null;
+ return (
+ {
+ const source = appAtomRegistry
+ .get(environmentThreadShells.threadShellsAtom)
+ .find((row) => scopedThreadKey(row.environmentId, row.id) === nativeEvent.itemId);
+ if (source)
+ void moveThread(source, { section: props.section, targetId: null, placement: "before" });
+ }}
+ >
+ {props.children ?? (
+
+
+ {props.section === "pinned"
+ ? "Pinned"
+ : props.section === "active"
+ ? "Active"
+ : "Settled"}
+
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index 0c7fa5a3688f..3d8326fc29fe 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -1477,3 +1477,13 @@ describe("cross-section thread drops", () => {
).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
});
});
+
+it("keeps native drop targets available when Pinned, Active, and Settled are empty", () => {
+ expect(
+ buildThreadListV2ListItems({ items: [], pendingTasks: [], arrangementTargets: true }),
+ ).toEqual([
+ { type: "v2-drop-header", key: "v2-pinned-drop", section: "pinned" },
+ { type: "v2-drop-header", key: "v2-active-drop", section: "active" },
+ { type: "v2-drop-header", key: "v2-settled-drop", section: "settled" },
+ ]);
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 3629e63df462..ee74aec5a3f1 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -268,6 +268,11 @@ export interface ThreadListV2SettledShelfListItem {
}
export type ThreadListV2ListItem =
+ | {
+ readonly type: "v2-drop-header";
+ readonly key: string;
+ readonly section: "pinned" | "active" | "settled";
+ }
| ThreadListV2ThreadListItem
| ThreadListV2PendingListItem
| ThreadListV2SnoozedShelfListItem
@@ -279,6 +284,7 @@ export type ThreadListV2ListItem =
* reachable without competing with either the inbox or settled history.
*/
export function buildThreadListV2ListItems(input: {
+ readonly arrangementTargets?: boolean;
readonly items: ReadonlyArray;
readonly pendingTasks: ReadonlyArray;
readonly snoozedCount?: number;
@@ -310,7 +316,18 @@ export function buildThreadListV2ListItems(input: {
const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
- const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
+ const live = threadItems.slice(0, activeEnd);
+ const firstActive = live.findIndex((item) => item.type === "v2-thread" && !item.item.pinned);
+ const split = firstActive === -1 ? live.length : firstActive;
+ const result: ThreadListV2ListItem[] = input.arrangementTargets
+ ? [
+ { type: "v2-drop-header", key: "v2-pinned-drop", section: "pinned" },
+ ...live.slice(0, split),
+ { type: "v2-drop-header", key: "v2-active-drop", section: "active" },
+ ...live.slice(split),
+ ...pendingItems,
+ ]
+ : [...live, ...pendingItems];
if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
result.push({
type: "v2-snoozed-shelf",
@@ -328,6 +345,8 @@ export function buildThreadListV2ListItems(input: {
expanded: input.settledShelfExpanded !== false,
});
result.push(...threadItems.slice(settledShelfHeaderIndex));
+ } else if (input.arrangementTargets) {
+ result.push({ type: "v2-drop-header", key: "v2-settled-drop", section: "settled" });
}
return result;
}
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 360057dd56a1..9ef4251aa9ee 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -52,8 +52,11 @@ spot where the thread will land. Drops into either section keep the position you
mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between
**Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to
settle a thread. The dragged card shows the action before you release it. Expand **Snoozed**
-or **Settled** to drag a parked thread back into either live section. Each drop saves; **Done** returns to the thread list.
-**Move up** and **Move down** are also available in the thread menu. The server
+or **Settled** to drag a parked thread back into either live section. Each drop saves;
+**Done** returns to the thread list. **Move up** and **Move down** are also available in the
+thread menu. On iOS, hold a thread and move your finger to drag it directly, including after
+its context menu appears. Drop into **Pinned** or **Active** to pin, unpin, wake, or
+un-settle it, or onto the **Settled** divider to settle it. The server
saves the order, so it survives a refresh and appears on your other connected devices.
On web and desktop, the list also animates section changes made with thread actions such as
diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch
index 65e6f24a7901..e85a16be8066 100644
--- a/patches/@react-native-menu__menu@2.0.0.patch
+++ b/patches/@react-native-menu__menu@2.0.0.patch
@@ -1,8 +1,8 @@
diff --git a/ios/MenuViewManager.mm b/ios/MenuViewManager.mm
-index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..e80fe2caae86e339fcb03408419a7d64b3e5b04f 100644
+index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..140e250eaccc3f49665d21f439c5f0b28177baef 100644
--- a/ios/MenuViewManager.mm
+++ b/ios/MenuViewManager.mm
-@@ -65,6 +65,10 @@ - (UIView *)view
+@@ -65,6 +65,18 @@ RCT_EXPORT_VIEW_PROPERTY(onCloseMenu, RCTDirectEventBlock);
* onOpenMenu: callback to be called when the menu is opened
*/
RCT_EXPORT_VIEW_PROPERTY(onOpenMenu, RCTDirectEventBlock);
@@ -10,14 +10,26 @@ index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..e80fe2caae86e339fcb03408419a7d64
+ * onMenuInteractionStart: callback to be called when UIKit starts preparing a menu interaction
+ */
+RCT_EXPORT_VIEW_PROPERTY(onMenuInteractionStart, RCTDirectEventBlock);
++RCT_EXPORT_VIEW_PROPERTY(dragItemId, NSString);
++RCT_EXPORT_VIEW_PROPERTY(dragEnabled, BOOL);
++RCT_EXPORT_VIEW_PROPERTY(dropEnabled, BOOL);
++RCT_EXPORT_VIEW_PROPERTY(dragGroup, NSString);
++RCT_EXPORT_VIEW_PROPERTY(dragActionLabels, NSString);
++RCT_EXPORT_VIEW_PROPERTY(dropSection, NSString);
++RCT_EXPORT_VIEW_PROPERTY(onDragBegin, RCTDirectEventBlock);
++RCT_EXPORT_VIEW_PROPERTY(onItemDrop, RCTDirectEventBlock);
/**
* shouldOpenOnLongPress: determines whether menu should be opened after long press or normal press
*/
diff --git a/ios/NewArch/FabricActionSheetView.swift b/ios/NewArch/FabricActionSheetView.swift
-index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..bc92310d00369b853a80f4d2080578f3903191d2 100644
+index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..50645853f7aade947eff730472fd3eead3dcb368 100644
--- a/ios/NewArch/FabricActionSheetView.swift
+++ b/ios/NewArch/FabricActionSheetView.swift
-@@ -3,6 +3,7 @@ public class FabricActionSheetView: ActionSheetView, FabricViewImplementationPro
+@@ -1,8 +1,11 @@
+ @objc(FabricActionSheetView)
+ public class FabricActionSheetView: ActionSheetView, FabricViewImplementationProtocol {
++ public var onDragBegin: (() -> Void)?
++ public var onItemDrop: ((String, String) -> Void)?
public var onPressAction: ((String) -> Void)?
public var onCloseMenu: (() -> Void)?
public var onOpenMenu: (() -> Void)?
@@ -26,10 +38,15 @@ index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..bc92310d00369b853a80f4d2080578f3
@objc override func sendButtonAction(_ action: String) {
if let onPress = onPressAction {
diff --git a/ios/NewArch/FabricMenuViewImplementation.swift b/ios/NewArch/FabricMenuViewImplementation.swift
-index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea8629e79d0c 100644
+index 33d27c5a66593ae1a9b143804413578eb79f1a7d..155cae6d627c917681b54038639429e8f3d40fa6 100644
--- a/ios/NewArch/FabricMenuViewImplementation.swift
+++ b/ios/NewArch/FabricMenuViewImplementation.swift
-@@ -12,6 +12,7 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
+@@ -9,9 +9,12 @@ import UIKit
+ @available(iOS 14.0, *)
+ @objc(FabricMenuViewImplementation)
+ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImplementationProtocol {
++ public var onDragBegin: (() -> Void)?
++ public var onItemDrop: ((String, String) -> Void)?
public var onPressAction: ((String) -> Void)?
public var onCloseMenu: (() -> Void)?
public var onOpenMenu: (() -> Void)?
@@ -37,10 +54,13 @@ index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea86
@objc override func sendButtonAction(_ action: UIAction) {
if let onPress = onPressAction {
-@@ -31,4 +32,10 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
+@@ -31,4 +34,13 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
}
}
++ @objc override func sendDragBegin() { onDragBegin?() }
++ @objc override func sendItemDrop(_ itemId: String, placement: String) { onItemDrop?(itemId, placement) }
++
+ @objc override func sendMenuInteractionStart() {
+ if let onMenuInteractionStart = onMenuInteractionStart {
+ onMenuInteractionStart()
@@ -49,31 +69,54 @@ index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea86
+
}
diff --git a/ios/NewArch/FabricViewImplementationProtocol.swift b/ios/NewArch/FabricViewImplementationProtocol.swift
-index 2cef2fb8c74497da46775a490edc58cf8cbf29cc..54429c756eeecf6e96b55d62cc1e70213096f62a 100644
+index 2cef2fb8c74497da46775a490edc58cf8cbf29cc..5e387fbd36845cc3064b62bd6afa66ff2c189a40 100644
--- a/ios/NewArch/FabricViewImplementationProtocol.swift
+++ b/ios/NewArch/FabricViewImplementationProtocol.swift
-@@ -8,4 +8,5 @@ import Foundation
+@@ -1,5 +1,13 @@
+ import Foundation
+ @objc public protocol FabricViewImplementationProtocol {
++ var dragEnabled: Bool { get set }
++ var dropEnabled: Bool { get set }
++ var dragItemId: NSString? { get set }
++ var dragGroup: NSString? { get set }
++ var dragActionLabels: NSString? { get set }
++ var dropSection: NSString? { get set }
++ var onDragBegin: (() -> Void)? { get set }
++ var onItemDrop: ((String, String) -> Void)? { get set }
+ var actions: [NSDictionary]? { get set }
+ var title: NSString? { get set }
+ var themeVariant: NSString? { get set }
+@@ -8,4 +16,5 @@ import Foundation
var onPressAction: ((String) -> Void)? { get set }
var onCloseMenu: (() -> Void)? { get set }
var onOpenMenu: (() -> Void)? { get set }
+ var onMenuInteractionStart: (() -> Void)? { get set }
}
diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm
-index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f3935e445 100644
+index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..cd54ee06deeedef6d7e85bcfcc531b46fd5fe3b6 100644
--- a/ios/NewArch/MenuView.mm
+++ b/ios/NewArch/MenuView.mm
-@@ -46,6 +46,10 @@ - (instancetype)initWithFrame:(CGRect)frame
+@@ -46,6 +46,19 @@ using namespace facebook::react;
[self onOpenMenu];
};
+ _view.onMenuInteractionStart = ^{
+ [self onMenuInteractionStart];
+ };
++
++ _view.onDragBegin = ^{
++ const auto emitter = [self getEventEmitter];
++ if (emitter != nullptr) emitter->onDragBegin({});
++ };
++ _view.onItemDrop = ^(NSString *itemId, NSString *placement) {
++ const auto emitter = [self getEventEmitter];
++ if (emitter != nullptr) emitter->onItemDrop({std::string([itemId UTF8String]), std::string([placement UTF8String])});
++ };
+
_view.onCloseMenu = ^{
[self onCloseMenu];
};
-@@ -92,6 +96,14 @@ - (void)onOpenMenu {
+@@ -92,6 +105,14 @@ using namespace facebook::react;
}
}
@@ -88,7 +131,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f
/**
Responsible for iterating through the C++ vector and convert each struct element to NSDictionary, then return it all in an NSArray
*/
-@@ -105,6 +117,27 @@ - (void)onOpenMenu {
+@@ -105,6 +126,27 @@ using namespace facebook::react;
NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()];
if (action.subactions.size() > 0) {
for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {
@@ -116,7 +159,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f
NSDictionary *subactionDict = @{
@"id": [NSString stringWithUTF8String:subaction.id.c_str()],
@"title": [NSString stringWithUTF8String:subaction.title.c_str()],
-@@ -118,7 +151,9 @@ - (void)onOpenMenu {
+@@ -118,7 +160,9 @@ using namespace facebook::react;
@"destructive": @(subaction.attributes.destructive),
@"disabled": @(subaction.attributes.disabled),
@"hidden": @(subaction.attributes.hidden),
@@ -126,7 +169,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f
};
[subactionsArray addObject:subactionDict];
}
-@@ -138,6 +173,7 @@ - (void)onOpenMenu {
+@@ -138,6 +182,7 @@ using namespace facebook::react;
@"destructive": @(action.attributes.destructive),
@"disabled": @(action.attributes.disabled),
@"hidden": @(action.attributes.hidden),
@@ -134,11 +177,41 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f
},
@"subactions": subactionsArray,
};
+@@ -153,6 +198,25 @@ using namespace facebook::react;
+ const auto &newViewProps = *std::static_pointer_cast(props);
+
+
++ if (oldViewProps.dropEnabled != newViewProps.dropEnabled) {
++ _view.dropEnabled = newViewProps.dropEnabled;
++ }
++ if (oldViewProps.dragEnabled != newViewProps.dragEnabled) {
++ _view.dragEnabled = newViewProps.dragEnabled;
++ }
++ if (oldViewProps.dragItemId != newViewProps.dragItemId) {
++ _view.dragItemId = [NSString stringWithUTF8String:newViewProps.dragItemId.c_str()];
++ }
++ if (oldViewProps.dragActionLabels != newViewProps.dragActionLabels) {
++ _view.dragActionLabels = [NSString stringWithUTF8String:newViewProps.dragActionLabels.c_str()];
++ }
++ if (oldViewProps.dropSection != newViewProps.dropSection) {
++ _view.dropSection = [NSString stringWithUTF8String:newViewProps.dropSection.c_str()];
++ }
++ if (oldViewProps.dragGroup != newViewProps.dragGroup) {
++ _view.dragGroup = [NSString stringWithUTF8String:newViewProps.dragGroup.c_str()];
++ }
++
+ if (oldViewProps.actionsHash != newViewProps.actionsHash) {
+ _view.actions = [self convertActionsToObjC: newViewProps.actions];
+ }
diff --git a/ios/OldArch/LegacyActionSheetView.swift b/ios/OldArch/LegacyActionSheetView.swift
-index 3cd1a2517624d99b51331c564fba9145d7c8d799..e458504bea6ef167d9b0db0c889f0f76ea965287 100644
+index 3cd1a2517624d99b51331c564fba9145d7c8d799..848ee288fd37ab4af9fefffbb146a689bf95cb61 100644
--- a/ios/OldArch/LegacyActionSheetView.swift
+++ b/ios/OldArch/LegacyActionSheetView.swift
-@@ -3,6 +3,7 @@ public class LegacyActionSheetView: ActionSheetView {
+@@ -1,8 +1,11 @@
+ @objc(LegacyActionSheetView)
+ public class LegacyActionSheetView: ActionSheetView {
++ @objc var onDragBegin: RCTDirectEventBlock?
++ @objc var onItemDrop: RCTDirectEventBlock?
@objc var onPressAction: RCTDirectEventBlock?
@objc var onCloseMenu: RCTDirectEventBlock?
@objc var onOpenMenu: RCTDirectEventBlock?
@@ -147,10 +220,15 @@ index 3cd1a2517624d99b51331c564fba9145d7c8d799..e458504bea6ef167d9b0db0c889f0f76
diff --git a/ios/OldArch/LegacyMenuViewImplementation.swift b/ios/OldArch/LegacyMenuViewImplementation.swift
-index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256a76aab9c 100644
+index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..5fcce7d003d2ff0c6b725e81c6380269e6e2c39e 100644
--- a/ios/OldArch/LegacyMenuViewImplementation.swift
+++ b/ios/OldArch/LegacyMenuViewImplementation.swift
-@@ -5,6 +5,7 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
+@@ -2,9 +2,12 @@ import UIKit
+ @available(iOS 14.0, *)
+ @objc(LegacyMenuViewImplementation)
+ public class LegacyMenuViewImplementation: MenuViewImplementation {
++ @objc var onDragBegin: RCTDirectEventBlock?
++ @objc var onItemDrop: RCTDirectEventBlock?
@objc var onPressAction: RCTDirectEventBlock?
@objc var onCloseMenu: RCTDirectEventBlock?
@objc var onOpenMenu: RCTDirectEventBlock?
@@ -158,10 +236,15 @@ index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256
@objc override func sendButtonAction(_ action: UIAction) {
if let onPress = onPressAction {
-@@ -24,4 +25,10 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
+@@ -24,4 +27,15 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
}
}
++ @objc override func sendDragBegin() { onDragBegin?([:]) }
++ @objc override func sendItemDrop(_ itemId: String, placement: String) {
++ onItemDrop?(["itemId": itemId, "placement": placement])
++ }
++
+ @objc override func sendMenuInteractionStart() {
+ if let onMenuInteractionStart = onMenuInteractionStart {
+ onMenuInteractionStart([:])
@@ -169,11 +252,334 @@ index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256
+ }
+
}
+diff --git a/ios/Shared/ActionSheetView.swift b/ios/Shared/ActionSheetView.swift
+index 352d8fa656aac2e39160104df2394ebe6719bac5..220c03803adff58985bfd15f60e9521ddf240657 100644
+--- a/ios/Shared/ActionSheetView.swift
++++ b/ios/Shared/ActionSheetView.swift
+@@ -17,6 +17,12 @@ public class ActionSheetView: UIView {
+ }
+
+ private var _actions: [UIAlertAction] = []
++ @objc public var dropEnabled: Bool = false
++ @objc public var dragActionLabels: NSString?
++ @objc public var dropSection: NSString?
++ @objc public var dragEnabled: Bool = false
++ @objc public var dragItemId: NSString?
++ @objc public var dragGroup: NSString?
+ @objc public var actions: [NSDictionary]? {
+ didSet {
+ guard let actions = self.actions else {
diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
-index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c77f2cc47 100644
+index 5c4e0da4292b15d3a27b5ea1555f11452a470815..d74908dfa79da88d3980e54c355196e0c339ace6 100644
--- a/ios/Shared/MenuViewImplementation.swift
+++ b/ios/Shared/MenuViewImplementation.swift
-@@ -60,17 +60,46 @@ public class MenuViewImplementation: UIButton {
+@@ -8,7 +8,304 @@
+ import UIKit
+ @available(iOS 14.0, *)
+ @objc(MenuViewImplementation)
+-public class MenuViewImplementation: UIButton {
++public class MenuViewImplementation: UIButton, UIDragInteractionDelegate, UIDropInteractionDelegate {
++ @objc public var dropEnabled: Bool = false
++ @objc public var dragActionLabels: NSString?
++ @objc public var dropSection: NSString?
++ @objc public var dragEnabled: Bool = false { didSet { itemDragInteraction?.isEnabled = dragEnabled } }
++ @objc public var dragItemId: NSString? { didSet { updateDragInteractions() } }
++ @objc public var dragGroup: NSString? { didSet { updateDragInteractions() } }
++ @objc func sendDragBegin() {}
++ @objc func sendItemDrop(_ itemId: String, placement: String) {}
++ private var itemDragInteraction: UIDragInteraction?
++ private var itemDropInteraction: UIDropInteraction?
++ private let insertionLine = CALayer()
++ private var scrollTimer: Timer?
++ private var reorderPreview: ReorderPreview?
++
++ private final class LocalDragItem {
++ let id: String
++ let group: String
++ var actionLabels: [String: String] = [:]
++ var preview: ReorderPreview?
++ init(id: String, group: String) { self.id = id; self.group = group }
++ }
++
++ // Animate temporary pictures, leaving UIKit's drop targets and React Native's
++ // virtualized layout in their original coordinates throughout the gesture.
++ private final class ReorderPreview {
++ private struct Row {
++ weak var menu: MenuViewImplementation?
++ weak var host: UIView?
++ let id: String
++ let frame: CGRect
++ let opacity: Float
++ let picture: UIView?
++ }
++ private weak var scroll: UIScrollView?
++ private let sourceId: String
++ private let group: String
++ private let sourceFrame: CGRect
++ private var rows: [Row] = []
++ private var insertion: CGFloat
++ private let actionLabels: [String: String]
++ private let badge = UILabel()
++
++ init?(source: MenuViewImplementation, scroll: UIScrollView, item: LocalDragItem) {
++ guard let host = source.itemDragInteraction?.view else { return nil }
++ self.scroll = scroll
++ actionLabels = item.actionLabels
++ sourceId = item.id
++ group = item.group
++ sourceFrame = host.convert(host.bounds, to: scroll)
++ insertion = sourceFrame.minY
++ badge.isUserInteractionEnabled = false
++ badge.isHidden = true
++ badge.font = .systemFont(ofSize: 13, weight: .semibold)
++ badge.textColor = .label
++ badge.backgroundColor = .secondarySystemBackground
++ badge.textAlignment = .center
++ badge.layer.cornerRadius = 8
++ badge.clipsToBounds = true
++ scroll.window?.addSubview(badge)
++ refresh()
++ }
++
++ func refresh() {
++ guard let scroll else { return }
++ // Recycled cells must never retain the old thread's picture or opacity.
++ rows.removeAll { row in
++ if row.menu?.dragItemId as String? == row.id,
++ row.host?.isDescendant(of: scroll) == true { return false }
++ row.host?.layer.opacity = row.opacity
++ row.picture?.removeFromSuperview()
++ return true
++ }
++ func visit(_ view: UIView) {
++ if let menu = view as? MenuViewImplementation,
++ menu.dragGroup as String? == group,
++ let id = menu.dragItemId as String?,
++ let host = menu.itemDragInteraction?.view,
++ !rows.contains(where: { $0.host === host }) {
++ let frame = host.convert(host.bounds, to: scroll)
++ if frame.intersects(scroll.bounds), frame.height > 0 {
++ let picture = id == sourceId ? nil : host.snapshotView(afterScreenUpdates: false)
++ if id == sourceId || picture != nil {
++ let row = Row(menu: menu, host: host, id: id, frame: frame,
++ opacity: host.layer.opacity, picture: picture)
++ rows.append(row)
++ if let picture {
++ picture.isUserInteractionEnabled = false
++ picture.frame = frame
++ scroll.addSubview(picture)
++ }
++ // UIView.alpha would remove the underlying view from hit testing.
++ host.layer.opacity = 0
++ }
++ }
++ }
++ for child in view.subviews { visit(child) }
++ }
++ visit(scroll)
++ apply(animated: false)
++ }
++
++ func update(location: CGPoint) {
++ guard let scroll else { return }
++ if let window = scroll.window {
++ let point = scroll.convert(location, to: window)
++ badge.center = CGPoint(
++ x: max(badge.bounds.width / 2 + 8, min(window.bounds.width - badge.bounds.width / 2 - 8, point.x)),
++ y: min(window.bounds.height - window.safeAreaInsets.bottom - badge.bounds.height / 2 - 8,
++ point.y + sourceFrame.height / 2 + 16))
++ }
++ let row = rows.first(where: { $0.frame.contains(location) })
++ if scroll.bounds.contains(location), let row, row.id != sourceId,
++ let menu = row.menu, menu.dropEnabled, actionLabels[menu.dropSection as String? ?? ""] != nil {
++ update(target: menu, before: location.y < row.frame.midY)
++ } else {
++ badge.isHidden = true
++ if insertion != sourceFrame.minY {
++ insertion = sourceFrame.minY
++ apply(animated: true)
++ }
++ }
++ }
++
++ func update(target: MenuViewImplementation, before: Bool) {
++ guard let scroll, let host = target.itemDropInteraction?.view,
++ host.isDescendant(of: scroll) else { return }
++ let action = actionLabels[target.dropSection as String? ?? ""]
++ badge.isHidden = action == nil
++ if badge.text != action {
++ badge.text = action
++ badge.sizeToFit()
++ badge.bounds.size.width += 20
++ badge.bounds.size.height += 12
++ }
++ let frame = host.convert(host.bounds, to: scroll)
++ // Section headings insert below the heading, including empty sections.
++ let next = target.dragEnabled && before ? frame.minY : frame.maxY
++ guard next != insertion else { return }
++ insertion = next
++ apply(animated: true)
++ }
++
++ private func apply(animated: Bool) {
++ let changes = {
++ for row in self.rows {
++ var shift: CGFloat = 0
++ if row.id != self.sourceId {
++ if self.insertion <= self.sourceFrame.minY {
++ if row.frame.minY >= self.insertion && row.frame.minY < self.sourceFrame.minY {
++ shift = self.sourceFrame.height
++ }
++ } else if row.frame.minY > self.sourceFrame.minY && row.frame.minY < self.insertion {
++ shift = -self.sourceFrame.height
++ }
++ }
++ row.picture?.transform = CGAffineTransform(translationX: 0, y: shift)
++ }
++ }
++ if animated && !UIAccessibility.isReduceMotionEnabled {
++ UIView.animate(withDuration: 0.16, delay: 0,
++ options: [.beginFromCurrentState, .allowUserInteraction], animations: changes)
++ } else { changes() }
++ }
++
++ func finish() {
++ badge.removeFromSuperview()
++ for row in rows {
++ row.host?.layer.opacity = row.opacity
++ row.picture?.removeFromSuperview()
++ }
++ rows.removeAll()
++ scroll = nil
++ }
++ }
++
++ private func updateDragInteractions() {
++ let host = shouldOpenOnLongPress ? superview : nil
++ let enabled = host != nil && (dragItemId?.length ?? 0) > 0 && (dragGroup?.length ?? 0) > 0
++ if enabled, itemDragInteraction?.view === host { return }
++ if let interaction = itemDragInteraction { interaction.view?.removeInteraction(interaction) }
++ if let interaction = itemDropInteraction { interaction.view?.removeInteraction(interaction) }
++ itemDragInteraction = nil
++ itemDropInteraction = nil
++ insertionLine.removeFromSuperlayer()
++ guard enabled, let host else { return }
++ let drag = UIDragInteraction(delegate: self)
++ drag.isEnabled = dragEnabled
++ let drop = UIDropInteraction(delegate: self)
++ host.addInteraction(drag)
++ host.addInteraction(drop)
++ itemDragInteraction = drag
++ itemDropInteraction = drop
++ }
++
++ public func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
++ guard dragEnabled, let id = dragItemId as String?, !id.isEmpty,
++ let group = dragGroup as String?, !group.isEmpty else { return [] }
++ let item = UIDragItem(itemProvider: NSItemProvider())
++ let local = LocalDragItem(id: id, group: group)
++ if let data = (dragActionLabels as String?)?.data(using: .utf8),
++ let labels = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
++ local.actionLabels = labels
++ }
++ item.localObject = local
++ sendDragBegin()
++ return [item]
++ }
++
++ public func dragInteraction(_ interaction: UIDragInteraction, sessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { true }
++
++ public func dragInteraction(_ interaction: UIDragInteraction, sessionWillBegin session: UIDragSession) {
++ var ancestor = interaction.view?.superview
++ while let view = ancestor, !(view is UIScrollView) { ancestor = view.superview }
++ guard let scroll = ancestor as? UIScrollView else { return }
++ scrollTimer?.invalidate()
++ if let item = session.items.first?.localObject as? LocalDragItem {
++ let preview = ReorderPreview(source: self, scroll: scroll, item: item)
++ item.preview = preview
++ reorderPreview = preview
++ }
++ // Run only during a drag, including when the finger is stationary at
++ // the edge. The scroll delegate keeps React Native's list in sync.
++ let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak scroll, weak self] _ in
++ guard let scroll else { return }
++ let location = session.location(in: scroll)
++ self?.reorderPreview?.update(location: location)
++ guard location.x >= scroll.bounds.minX, location.x <= scroll.bounds.maxX else { return }
++ let y = location.y - scroll.bounds.minY
++ let edge: CGFloat = 48
++ let speed: CGFloat = y < edge ? -min(1, (edge - y) / edge)
++ : y > scroll.bounds.height - edge ? min(1, (y - scroll.bounds.height + edge) / edge) : 0
++ guard speed != 0 else { return }
++ let minimum = -scroll.adjustedContentInset.top
++ let maximum = max(minimum, scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom)
++ let offset = max(minimum, min(maximum, scroll.contentOffset.y + speed * 8))
++ if offset != scroll.contentOffset.y {
++ scroll.setContentOffset(CGPoint(x: scroll.contentOffset.x, y: offset), animated: false)
++ self?.reorderPreview?.refresh()
++ }
++ }
++ scrollTimer = timer
++ RunLoop.main.add(timer, forMode: .common)
++ }
++
++ public func dragInteraction(_ interaction: UIDragInteraction, session: UIDragSession, didEndWith operation: UIDropOperation) {
++ scrollTimer?.invalidate()
++ scrollTimer = nil
++ reorderPreview?.finish()
++ reorderPreview = nil
++ }
++
++ deinit { scrollTimer?.invalidate(); reorderPreview?.finish() }
++
++ private func localItem(_ session: UIDropSession) -> LocalDragItem? {
++ guard dropEnabled, session.localDragSession != nil, session.items.count == 1,
++ let item = session.items.first?.localObject as? LocalDragItem,
++ item.group == dragGroup as String?, item.id != dragItemId as String? else { return nil }
++ if let section = dropSection as String?, !section.isEmpty, item.actionLabels[section] == nil { return nil }
++ return item
++ }
++
++ public func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
++ localItem(session) != nil
++ }
++
++ public func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
++ guard let item = localItem(session), let host = interaction.view else {
++ return UIDropProposal(operation: .forbidden)
++ }
++ let before = session.location(in: host).y < host.bounds.midY
++ if let preview = item.preview {
++ preview.update(target: self, before: before)
++ return UIDropProposal(operation: .move)
++ }
++ CATransaction.begin()
++ CATransaction.setDisableActions(true)
++ insertionLine.backgroundColor = host.tintColor.cgColor
++ insertionLine.frame = CGRect(x: 12, y: before ? 0 : host.bounds.height - 3, width: max(0, host.bounds.width - 24), height: 3)
++ host.layer.addSublayer(insertionLine)
++ CATransaction.commit()
++ return UIDropProposal(operation: .move)
++ }
++
++ public func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
++ insertionLine.removeFromSuperlayer()
++ guard let item = localItem(session), let host = interaction.view else { return }
++ let placement = session.location(in: host).y < host.bounds.midY ? "before" : "after"
++ sendItemDrop(item.id, placement: placement)
++ }
++
++ public func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: UIDropSession) {
++ insertionLine.removeFromSuperlayer()
++ }
++ public func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: UIDropSession) {
++ insertionLine.removeFromSuperlayer()
++ }
++
+
+ @objc public var actions: [NSDictionary]? {
+ didSet {
+@@ -60,17 +357,46 @@ public class MenuViewImplementation: UIButton {
}
public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
@@ -222,7 +628,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c
func setup () {
let menu = UIMenu(title: _title,
identifier: nil,
-@@ -86,8 +115,98 @@ public class MenuViewImplementation: UIButton {
+@@ -86,8 +412,99 @@ public class MenuViewImplementation: UIButton {
}
}
@@ -302,6 +708,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c
+ }
+
+ private func updateLongPressInteraction() {
++ updateDragInteractions()
+ if shouldOpenOnLongPress, let host = self.superview {
+ if let existing = longPressInteraction, existing.view === host {
+ return
@@ -321,7 +728,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c
}
public override func reactSetFrame(_ frame: CGRect) {
-@@ -124,4 +243,8 @@ public class MenuViewImplementation: UIButton {
+@@ -124,4 +541,8 @@ public class MenuViewImplementation: UIButton {
@objc func sendMenuOpen() {
// NO-OP (should be overriden by parent)
}
@@ -355,10 +762,21 @@ index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f6
if #available(iOS 16.0, *) {
diff --git a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
-index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
+index e6509355275596c451f9d082223cc16bfe504e1a..62f83265ee2052dd392d0d8d3fa545854cecc6bd 100644
--- a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
+++ b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
-@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
+@@ -45,9 +45,18 @@ type MenuAction = {
+ subactions?: Array;
+ };
+ export interface NativeProps extends ViewProps {
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: DirectEventHandler<{ event: string }>;
++ onItemDrop?: DirectEventHandler<{ itemId: string; placement: string }>;
onPressAction?: DirectEventHandler<{ event: string }>;
onCloseMenu?: DirectEventHandler<{ event: string }>;
onOpenMenu?: DirectEventHandler<{ event: string }>;
@@ -367,10 +785,21 @@ index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed995
actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
title?: string;
diff --git a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
-index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
+index e6509355275596c451f9d082223cc16bfe504e1a..62f83265ee2052dd392d0d8d3fa545854cecc6bd 100644
--- a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
+++ b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
-@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
+@@ -45,9 +45,18 @@ type MenuAction = {
+ subactions?: Array;
+ };
+ export interface NativeProps extends ViewProps {
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: DirectEventHandler<{ event: string }>;
++ onItemDrop?: DirectEventHandler<{ itemId: string; placement: string }>;
onPressAction?: DirectEventHandler<{ event: string }>;
onCloseMenu?: DirectEventHandler<{ event: string }>;
onOpenMenu?: DirectEventHandler<{ event: string }>;
@@ -379,10 +808,25 @@ index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed995
actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
title?: string;
diff --git a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
-index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba70584055f9fb53 100644
+index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..4a1cbca742dfb7041be52e7201f6a475d6eac160 100644
--- a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
+++ b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
-@@ -41,6 +41,9 @@ export interface NativeProps extends ViewProps {
+@@ -32,6 +32,14 @@ type MenuAction = {
+ subactions?: Array;
+ };
+ export interface NativeProps extends ViewProps {
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: DirectEventHandler<{ event: string }>;
++ onItemDrop?: DirectEventHandler<{ itemId: string; placement: string }>;
+ onPressAction?: DirectEventHandler<{
+ event: string;
+ }>;
+@@ -41,6 +49,9 @@ export interface NativeProps extends ViewProps {
onOpenMenu?: DirectEventHandler<{
event: string;
}>;
@@ -393,22 +837,31 @@ index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba705840
actionsHash: string;
title?: string;
diff --git a/lib/typescript/src/index.d.ts b/lib/typescript/src/index.d.ts
-index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..84ac2bc8b4edac045ed59896d5eb4e6eaef4725d 100644
+index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..e2c420b0ba331530de66516a1dcf9612a18221f1 100644
--- a/lib/typescript/src/index.d.ts
+++ b/lib/typescript/src/index.d.ts
-@@ -4,6 +4,7 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{
+@@ -4,6 +4,16 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{
onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
onCloseMenu?: () => void;
onOpenMenu?: () => void;
+ onMenuInteractionStart?: () => void;
++ /** Local iOS drag identity. Empty values disable drag and drop. */
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: () => void;
++ onItemDrop?: (event: { nativeEvent: { itemId: string; placement: string } }) => void;
actions: MenuAction[];
title?: string;
isAnchoredToRight?: boolean;
diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts
-index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..271cd27f777be06bc02f05d4b4dac5bb940f5f0f 100644
+index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..901105933ea133236f411475bb0d2efa03a48633 100644
--- a/lib/typescript/src/types.d.ts
+++ b/lib/typescript/src/types.d.ts
-@@ -113,6 +113,12 @@ type MenuComponentPropsBase = {
+@@ -113,6 +113,21 @@ type MenuComponentPropsBase = {
* Callback function that will be called when the menu opens.
*/
onOpenMenu?: () => void;
@@ -418,19 +871,37 @@ index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..271cd27f777be06bc02f05d4b4dac5bb
+ * @platform iOS
+ */
+ onMenuInteractionStart?: () => void;
++ /** Local iOS drag identity. Empty values disable drag and drop. */
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: () => void;
++ onItemDrop?: (event: { nativeEvent: { itemId: string; placement: string } }) => void;
/**
* Actions to be displayed in the menu.
*/
-@@ -170,6 +176,7 @@ export type NativeMenuComponentProps = {
+@@ -170,6 +185,16 @@ export type NativeMenuComponentProps = {
onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
onCloseMenu?: () => void;
onOpenMenu?: () => void;
+ onMenuInteractionStart?: () => void;
++ /** Local iOS drag identity. Empty values disable drag and drop. */
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: () => void;
++ onItemDrop?: (event: { nativeEvent: { itemId: string; placement: string } }) => void;
actions: ProcessedMenuAction[];
actionsHash: string;
title?: string;
diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
-index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565de2391d4 100644
+index e6509355275596c451f9d082223cc16bfe504e1a..e93f02778ab503b1c8c3adfdce8984d706b04864 100644
--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts
+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati
@@ -468,7 +939,7 @@ index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565
};
type MenuAction = {
id?: string;
-@@ -41,6 +61,7 @@ type MenuAction = {
+@@ -41,13 +61,23 @@ type MenuAction = {
destructive?: boolean;
disabled?: boolean;
hidden?: boolean;
@@ -476,7 +947,15 @@ index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565
};
subactions?: Array;
};
-@@ -48,6 +69,7 @@ export interface NativeProps extends ViewProps {
+ export interface NativeProps extends ViewProps {
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: DirectEventHandler<{ event: string }>;
++ onItemDrop?: DirectEventHandler<{ itemId: string; placement: string }>;
onPressAction?: DirectEventHandler<{ event: string }>;
onCloseMenu?: DirectEventHandler<{ event: string }>;
onOpenMenu?: DirectEventHandler<{ event: string }>;
@@ -485,10 +964,10 @@ index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565
actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
title?: string;
diff --git a/src/types.ts b/src/types.ts
-index 9c323d9c510e7359c83d4d6633affde673570632..5d5fdfb8b8235fcaa8295b85630443eaf5991a17 100644
+index 9c323d9c510e7359c83d4d6633affde673570632..945f05e863516b971dc52646005b85dd9517797d 100644
--- a/src/types.ts
+++ b/src/types.ts
-@@ -123,6 +123,12 @@ type MenuComponentPropsBase = {
+@@ -123,6 +123,21 @@ type MenuComponentPropsBase = {
* Callback function that will be called when the menu opens.
*/
onOpenMenu?: () => void;
@@ -498,14 +977,32 @@ index 9c323d9c510e7359c83d4d6633affde673570632..5d5fdfb8b8235fcaa8295b85630443ea
+ * @platform iOS
+ */
+ onMenuInteractionStart?: () => void;
++ /** Local iOS drag identity. Empty values disable drag and drop. */
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: () => void;
++ onItemDrop?: (event: { nativeEvent: { itemId: string; placement: string } }) => void;
/**
* Actions to be displayed in the menu.
*/
-@@ -189,6 +195,7 @@ export type NativeMenuComponentProps = {
+@@ -189,6 +204,16 @@ export type NativeMenuComponentProps = {
onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
onCloseMenu?: () => void;
onOpenMenu?: () => void;
+ onMenuInteractionStart?: () => void;
++ /** Local iOS drag identity. Empty values disable drag and drop. */
++ dragItemId?: string;
++ dragEnabled?: boolean;
++ dropEnabled?: boolean;
++ dragGroup?: string;
++ dragActionLabels?: string;
++ dropSection?: string;
++ onDragBegin?: () => void;
++ onItemDrop?: (event: { nativeEvent: { itemId: string; placement: string } }) => void;
actions: ProcessedMenuAction[];
actionsHash: string;
title?: string;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c58c455b465..fb640b283651 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -94,7 +94,7 @@ patchedDependencies:
'@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b
'@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
'@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
- '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
+ '@react-native-menu/menu@2.0.0': ef3d4bc7864dce4ec5f294f8bf5f9232d1568718448f4d501ae86be6fd05bb5c
'@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4
effect@4.0.0-rc.112: 8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2
@@ -261,7 +261,7 @@ importers:
version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
'@react-native-menu/menu':
specifier: ^2.0.0
- version: 2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ version: 2.0.0(patch_hash=ef3d4bc7864dce4ec5f294f8bf5f9232d1568718448f4d501ae86be6fd05bb5c)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
'@react-navigation/elements':
specifier: 2.9.26
version: 2.9.26(c10301b6e0c42fc6434d2b643197a81e)
@@ -15020,7 +15020,7 @@ snapshots:
react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
optional: true
- '@react-native-menu/menu@2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
+ '@react-native-menu/menu@2.0.0(patch_hash=ef3d4bc7864dce4ec5f294f8bf5f9232d1568718448f4d501ae86be6fd05bb5c)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
dependencies:
react: 19.2.3
react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)