import { memo, useCallback, useMemo, useState } from 'react';
import { ScrollView } from 'react-native';
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { GLASS_SURFACE } from '../../../lib/glass';
import { Surface } from '../../display/Surface/surface';
import { Menu, type MenuEntry } from '../../rows/menu';
import type { MenuItemIcon } from '../../rows/menu-item';
import { CONTEXT_MENU_STATE, HOLD_ITEM_TRANSFORM_DURATION, SPRING_CONFIGURATION_MENU } from './constants';
import { useHoldMenuInternal } from './context';
import { MENU_PANEL_DARK_COLOR, MENU_PANEL_LIGHT_COLOR } from './hold-menu-theme';
import type { HoldMenuIconComponent, MenuItemProps } from './hold-menu-types';
import { deepEqual, menuAnimationAnchor, resolveMenuPanelLeft } from './layout';
/**
* The leading icon for a `MenuItemProps` row, as a `Menu` `MenuItemIcon`.
*
* A consumer-supplied function icon renders itself — `HoldMenu` threads no
* colour into function icons, so it is called with no arguments. A string name
* is looked up through the provider's `AnimatedIcon`, sized by the row and
* painted with the themed colour the `Menu` row already resolved (`props` is the
* `IconProps` `MenuItem`'s default variant hands its icon).
*/
function toMenuIcon(icon: MenuItemProps['icon'], AnimatedIcon: HoldMenuIconComponent | null): MenuItemIcon | undefined {
if (!icon) return;
if (typeof icon === 'function') return () => icon();
if (!AnimatedIcon) return;
const name = icon;
return (props) => ;
}
/**
* `HoldMenu`'s `MenuItemProps` list, mapped onto the generic `Menu`'s entries.
*
* The row is now the generic `Menu`'s — the source of truth for the menu design
* — so `HoldMenu` translates its own item shape onto `Menu`'s: a title becomes a
* `label` caption, a destructive flag the `destructive` row, `withSeparator` a
* trailing `separator` entry, and `onPress` an `onSelect` that spreads the
* item's `actionParams` entry and then closes. The close itself is the `Menu`'s
* `onClose`, wired in the component so the panel starts leaving before the
* action runs, exactly as `Menu` documents.
*/
function toMenuEntries(
items: MenuItemProps[],
AnimatedIcon: HoldMenuIconComponent | null,
onSelectItem: (item: MenuItemProps) => void,
): MenuEntry[] {
const entries: MenuEntry[] = [];
for (const item of items) {
if (item.isTitle) entries.push({ type: 'label', label: item.text, testID: item.testID });
else
entries.push({
id: item.text,
label: item.text,
destructive: item.isDestructive,
disabled: item.disabled,
icon: toMenuIcon(item.icon, AnimatedIcon),
onSelect: () => onSelectItem(item),
testID: item.testID,
});
if (item.withSeparator) entries.push({ type: 'separator' });
}
return entries;
}
/**
* The panel — upstream's `MenuList`: the near-opaque surface that pops out of
* the corner nearest the held item.
*
* The panel's width is the widest row's content width — floored to the minimum
* and capped to 40% of the window (upstream used a fixed 60%) — resolved during
* activation into `menuProps.menuWidth`, so a short menu hugs its rows instead
* of spanning a fixed fraction of the screen. Its height is the effective
* `menuHeight` the activating `HoldItem` stored — already capped to the room left
* after travel — so a menu taller than the viewport scrolls instead of running
* off the screen, and its absolute left is clamped into the safe viewport.
*
* The rows are the generic `Menu`'s `'segmented'` variant — the hold-menu style
* `Menu` owns as the source of truth — over a near-opaque themed panel fill.
* Item rows cross into React state through the same `useAnimatedReaction` +
* `deepEqual` sync upstream uses.
*/
const MenuListComponent = () => {
const { glass = false, floating = false } = useHoldMenuInternal();
const { state, theme, menuProps, windowSize, safeAreaInsets, AnimatedIcon } = useHoldMenuInternal();
const [itemList, setItemList] = useState([]);
const prevList = useSharedValue([]);
const messageStyles = useAnimatedStyle(() => {
const menuWidth = menuProps.value.menuWidth;
const translate = menuAnimationAnchor(
menuProps.value.anchorPosition,
menuProps.value.itemWidth,
menuProps.value.menuHeight,
menuWidth,
);
const left = resolveMenuPanelLeft({
itemX: menuProps.value.itemX,
itemWidth: menuProps.value.itemWidth,
menuWidth,
windowWidth: windowSize.value.width,
safeLeft: safeAreaInsets.value.left,
safeRight: safeAreaInsets.value.right,
anchorPosition: menuProps.value.anchorPosition,
});
const menuScaleAnimation = () =>
state.value === CONTEXT_MENU_STATE.ACTIVE
? withSpring(1, SPRING_CONFIGURATION_MENU)
: withTiming(0, { duration: HOLD_ITEM_TRANSFORM_DURATION });
const opacityAnimation = () =>
withTiming(state.value === CONTEXT_MENU_STATE.ACTIVE ? 1 : 0, { duration: HOLD_ITEM_TRANSFORM_DURATION });
return {
left,
width: menuWidth,
height: menuProps.value.menuHeight,
opacity: opacityAnimation(),
transform: [
{ translateX: translate.beginningTransformations.translateX },
{ translateY: translate.beginningTransformations.translateY },
{ scale: menuScaleAnimation() },
{ translateX: translate.endingTransformations.translateX },
{ translateY: translate.endingTransformations.translateY },
],
};
}, [menuProps, windowSize, safeAreaInsets]);
const panelStyle = useAnimatedStyle(
() => ({
backgroundColor: theme.value === 'light' ? MENU_PANEL_LIGHT_COLOR : MENU_PANEL_DARK_COLOR,
}),
[theme],
);
const closeMenu = useCallback(() => {
state.value = CONTEXT_MENU_STATE.END;
}, [state]);
const handleSelect = useCallback(
(item: MenuItemProps) => {
const params = menuProps.value.actionParams[item.text] || [];
if (item.onPress) item.onPress(...params);
},
[menuProps],
);
const entries = useMemo(() => toMenuEntries(itemList, AnimatedIcon, handleSelect), [itemList, AnimatedIcon, handleSelect]);
const setter = (items: MenuItemProps[]) => {
// Deferred by one frame, like the twin's children pre-mount: the row list
// commits in response to the activation worklet's `menuProps` publish,
// which happens inside the LongPress gesture's `onStart` — mounting the
// rows (and starting their MotiView stagger animations) in the same tick
// as the gesture's touch dispatch is a native-crash risk on Android. One
// frame later the dispatch window has closed; the panel's 150 ms pop-in
// never notices the 16 ms delay.
requestAnimationFrame(() => {
setItemList(items);
prevList.value = items;
});
};
useAnimatedReaction(
() => menuProps.value.items,
(_items) => {
if (!deepEqual(_items, prevList.value)) runOnJS(setter)(_items);
},
[menuProps],
);
return (
{glass ? null : }
);
};
export const MenuList = memo(MenuListComponent);