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)