import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { type ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren, type ReactElement, type ReactNode, type Ref, } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, Modal, Platform, Pressable, StatusBar, Text, View, type GestureResponderEvent, type PressableProps, type PressableStateCallbackType, type StyleProp, type ViewStyle, } from "react-native"; import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb, isNative } from "@/constants/platform"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; type Placement = "top" | "bottom" | "left" | "right"; type Alignment = "start" | "center" | "end"; type MobileMenuMode = "dropdown" | "sheet"; interface Rect { x: number; y: number; width: number; height: number; } interface ContextMenuContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; anchorRect: Rect | null; setAnchorRect: (rect: Rect | null) => void; } const ContextMenuContext = createContext(null); function useContextMenuContext(componentName: string): ContextMenuContextValue { const ctx = useContext(ContextMenuContext); if (!ctx) { throw new Error(`${componentName} must be used within `); } return ctx; } export function useContextMenu(): ContextMenuContextValue { return useContextMenuContext("useContextMenu"); } function useControllableOpenState({ open, defaultOpen, onOpenChange, }: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }): [boolean, (next: boolean) => void] { const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen)); const isControlled = typeof open === "boolean"; const value = isControlled ? open : internalOpen; const setValue = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); return [value, setValue]; } function measureElement(element: View): Promise { return new Promise((resolve) => { element.measureInWindow((x, y, width, height) => { resolve({ x, y, width, height }); }); }); } function computePosition({ triggerRect, contentSize, displayArea, placement, alignment, offset, }: { triggerRect: Rect; contentSize: { width: number; height: number }; displayArea: Rect; placement: Placement; alignment: Alignment; offset: number; }): { x: number; y: number; actualPlacement: Placement } { const { width: contentWidth, height: contentHeight } = contentSize; // Calculate available space const spaceTop = triggerRect.y - displayArea.y; const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); // Flip if needed let actualPlacement = placement; if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { actualPlacement = "top"; } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { actualPlacement = "bottom"; } let x: number; let y: number; // Position based on placement if (actualPlacement === "bottom") { y = triggerRect.y + triggerRect.height + offset; } else if (actualPlacement === "top") { y = triggerRect.y - contentHeight - offset; } else if (actualPlacement === "left") { x = triggerRect.x - contentWidth - offset; y = triggerRect.y; } else { x = triggerRect.x + triggerRect.width + offset; y = triggerRect.y; } // Alignment if (actualPlacement === "top" || actualPlacement === "bottom") { if (alignment === "start") { x = triggerRect.x; } else if (alignment === "end") { x = triggerRect.x + triggerRect.width - contentWidth; } else { x = triggerRect.x + (triggerRect.width - contentWidth) / 2; } } // Constrain to screen const padding = 8; x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); y = Math.max( displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!), ); return { x, y, actualPlacement }; } function isCallable(fn: unknown): fn is (...args: unknown[]) => void { return typeof fn === "function"; } function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null { if (typeof event !== "object" || event === null) { return null; } const nativeEvent = Reflect.get(event, "nativeEvent"); const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event; const pageX = Reflect.get(native, "pageX"); const pageY = Reflect.get(native, "pageY"); if (typeof pageX === "number" && typeof pageY === "number") { return { pageX, pageY }; } const clientX = Reflect.get(native, "clientX"); const clientY = Reflect.get(native, "clientY"); if (typeof clientX === "number" && typeof clientY === "number") { return { pageX: clientX, pageY: clientY }; } return null; } function assignRef(ref: Ref | undefined, value: T): void { if (typeof ref === "function") { ref(value); return; } if (ref && typeof ref === "object") { Object.assign(ref, { current: value }); } } export function ContextMenu({ open, defaultOpen, onOpenChange, children, }: PropsWithChildren<{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }>): ReactElement { const triggerRef = useRef(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, defaultOpen, onOpenChange, }); const [anchorRect, setAnchorRect] = useState(null); useEffect(() => { if (!isOpen) { setAnchorRect(null); } }, [isOpen]); const value = useMemo( () => ({ open: isOpen, setOpen: setIsOpen, triggerRef, anchorRect, setAnchorRect, }), [anchorRect, isOpen, setAnchorRect, setIsOpen], ); return {children}; } interface TriggerState { pressed: boolean; hovered: boolean; open: boolean; } type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); export function ContextMenuTrigger({ children, disabled, style, enabled = true, enabledOnMobile = false, enabledOnWeb = true, longPressDelayMs, triggerRef, ...props }: PropsWithChildren< Omit & { style?: TriggerStyleProp; enabled?: boolean; enabledOnMobile?: boolean; enabledOnWeb?: boolean; longPressDelayMs?: number; triggerRef?: Ref; } >): ReactElement { const ctx = useContextMenuContext("ContextMenuTrigger"); const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile); const openAtEvent = useCallback( (event: unknown) => { if (!shouldEnableOnThisPlatform || disabled) { return; } const point = coerceEventPoint(event); if (!point) { return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; ctx.setAnchorRect({ x: point.pageX, y: point.pageY + statusBarHeight, width: 0, height: 0, }); ctx.setOpen(true); }, [ctx, disabled, shouldEnableOnThisPlatform], ); const handleRef = useCallback( (node: View | null) => { assignRef(ctx.triggerRef, node); assignRef(triggerRef, node); }, [ctx.triggerRef, triggerRef], ); const propsOnLongPress = props.onLongPress; const handleLongPress = useCallback( (event: GestureResponderEvent) => { if (isWeb) { propsOnLongPress?.(event); return; } openAtEvent(event); propsOnLongPress?.(event); }, [propsOnLongPress, openAtEvent], ); const handleContextMenu = useCallback( (event: unknown) => { if (isNative) { return; } if (typeof event === "object" && event !== null) { const preventDefault = Reflect.get(event, "preventDefault"); const stopPropagation = Reflect.get(event, "stopPropagation"); if (isCallable(preventDefault)) preventDefault.call(event); if (isCallable(stopPropagation)) stopPropagation.call(event); } openAtEvent(event); }, [openAtEvent], ); const pressableStyle = useCallback( ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { if (typeof style === "function") { return style({ pressed, hovered, open: ctx.open }); } return style; }, [style, ctx.open], ); return ( {children} ); } export function ContextMenuContent({ children, side = "bottom", align = "start", offset = 4, width, minWidth = 180, maxWidth, fullWidth = false, horizontalPadding = 16, mobileMode = "dropdown", testID, }: PropsWithChildren<{ side?: Placement; align?: Alignment; offset?: number; width?: number; minWidth?: number; maxWidth?: number; fullWidth?: boolean; horizontalPadding?: number; mobileMode?: MobileMenuMode; testID?: string; }>): ReactElement | null { const { t } = useTranslation(); const context = useContextMenuContext("ContextMenuContent"); const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); const { sheetRef: bottomSheetRef, handleSheetChange, handleSheetDismiss, } = useIsolatedBottomSheetVisibility({ visible: open, isEnabled: useMobileSheet, onClose: handleClose, }); const renderSheetBackdrop = useCallback( (props: ComponentProps) => ( ), [], ); const sheetBackgroundStyle = useMemo( () => [ styles.sheetBackground, { backgroundColor: theme.colors.surface0, borderColor: theme.colors.border, }, ], [theme.colors.surface0, theme.colors.border], ); const sheetHandleStyle = useMemo( () => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }], [theme.colors.surface2], ); // Measure trigger when opening (fallback) and capture point anchors. useEffect(() => { if (useMobileSheet || !open) { setTriggerRect(null); setContentSize(null); setPosition(null); return () => {}; } if (anchorRect) { setTriggerRect(anchorRect); return () => {}; } if (!triggerRef.current) { setTriggerRect(null); return () => {}; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; void measureElement(triggerRef.current).then((rect) => { if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); return undefined; }); return () => { cancelled = true; }; }, [anchorRect, open, triggerRef, useMobileSheet]); // Calculate position when we have both measurements useEffect(() => { if (useMobileSheet) return; if (!triggerRect || !contentSize) return; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight, }; const result = computePosition({ triggerRect, contentSize, displayArea, placement: side, alignment: align, offset, }); const x = fullWidth ? horizontalPadding : result.x; setPosition({ x, y: result.y }); }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]); const handleContentLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { const { width: w, height: h } = event.nativeEvent.layout; setContentSize({ width: w, height: h }); }, [], ); const frameStyle = useMemo(() => { const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } : { ...(typeof width === "number" ? { width } : null), ...(typeof minWidth === "number" ? { minWidth } : null), ...(typeof maxWidth === "number" ? { maxWidth } : null), }; return [ resolvedWidthStyle, { position: "absolute" as const, top: position?.y ?? -9999, left: position?.x ?? -9999, }, ]; }, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]); if (useMobileSheet) { return ( {children} ); } if (!open) return null; return ( {children} ); } export function ContextMenuLabel({ children, style, testID, }: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { const containerStyle = useMemo(() => [styles.labelContainer, style], [style]); return ( {children} ); } export function ContextMenuSeparator({ style, testID, }: { style?: ViewStyle; testID?: string; }): ReactElement { const separatorStyle = useMemo(() => [styles.separator, style], [style]); return ; } export function ContextMenuHint({ children, testID, }: PropsWithChildren<{ testID?: string }>): ReactElement { return ( {children} ); } function resolveLeadingContent(input: { isPending: boolean; isSuccess: boolean; leading: ReactElement | null | undefined; pendingColor: string; successColor: string; }): ReactElement | null { if (input.isPending) { return ; } if (input.isSuccess) { return ; } return input.leading ?? null; } function resolveItemLabel(input: { children: ReactNode; isPending: boolean; isSuccess: boolean; pendingLabel: string | undefined; successLabel: string | undefined; }): ReactNode { if (input.isPending && input.pendingLabel) return input.pendingLabel; if (input.isSuccess && input.successLabel) return input.successLabel; return input.children; } export function ContextMenuItem({ children, description, onSelect, disabled, destructive, selected, showSelectedCheck = false, selectedVariant = "default", leading, trailing, loading, status, pendingLabel, successLabel, closeOnSelect = true, testID, tooltip, }: PropsWithChildren<{ description?: string; onSelect?: () => void; disabled?: boolean; destructive?: boolean; selected?: boolean; showSelectedCheck?: boolean; selectedVariant?: "default" | "accent"; leading?: ReactElement | null; trailing?: ReactElement | null; /** @deprecated Use `status` instead */ loading?: boolean; status?: ActionStatus; pendingLabel?: string; successLabel?: string; closeOnSelect?: boolean; testID?: string; tooltip?: string; }>): ReactElement { const { theme } = useUnistyles(); const { setOpen } = useContextMenuContext("ContextMenuItem"); const isPending = status === "pending" || Boolean(loading); const isSuccess = status === "success"; const isDisabled = Boolean(disabled) || isPending || isSuccess; const leadingContent = resolveLeadingContent({ isPending, isSuccess, leading, pendingColor: theme.colors.foregroundMuted, successColor: theme.colors.palette.green[500], }); const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel }); const trailingContent = trailing ?? (!showSelectedCheck && selected ? ( ) : null); const handleItemPress = useCallback(() => { if (isDisabled) return; if (closeOnSelect) { setOpen(false); } onSelect?.(); }, [isDisabled, closeOnSelect, setOpen, onSelect]); const itemPressableStyle = useCallback( ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null; if (!selected) selectedStyle = null; else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent; else selectedStyle = styles.itemSelected; return [ styles.item, selectedStyle, selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, isDisabled ? styles.itemDisabled : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, ]; }, [selected, selectedVariant, isDisabled], ); const itemTextStyle = useMemo( () => [ styles.itemText, destructive && !isSuccess ? styles.itemTextDestructive : null, isSuccess ? styles.itemTextSuccess : null, selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, ], [destructive, isSuccess, selected, selectedVariant], ); const itemDescriptionStyle = useMemo( () => [ styles.itemDescription, selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null, ], [selected, selectedVariant], ); const content = ( {showSelectedCheck ? ( {selected ? : null} ) : null} {leadingContent ? {leadingContent} : null} {label} {description && !isPending && !isSuccess ? ( {description} ) : null} {trailingContent ? {trailingContent} : null} ); if (!tooltip) { return content; } return ( {content} {tooltip} ); } const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, }, backdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, }, content: { backgroundColor: theme.colors.surface0, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, ...theme.shadow.md, overflow: "hidden", }, sheetBackground: { backgroundColor: theme.colors.surface0, borderTopLeftRadius: theme.borderRadius.xl, borderTopRightRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.border, }, sheetHandle: { backgroundColor: theme.colors.surface2, }, sheetScrollContent: { paddingTop: theme.spacing[1], paddingBottom: theme.spacing[4], }, labelContainer: { paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, labelText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, textTransform: "uppercase", letterSpacing: 0.6, }, separator: { height: 1, backgroundColor: theme.colors.border, }, hintContainer: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, hintText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, item: { flexDirection: "row", alignItems: "center", minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderWidth: theme.borderWidth[1], borderColor: "transparent", }, itemHovered: { backgroundColor: theme.colors.surface1, }, itemPressed: { backgroundColor: theme.colors.surface1, }, itemDisabled: { opacity: 0.5, }, itemSelected: { backgroundColor: theme.colors.surface1, }, itemSelectedInteractive: { backgroundColor: theme.colors.surface1, }, itemSelectedAccent: { backgroundColor: theme.colors.accent, }, checkSlot: { width: 16, alignItems: "center", justifyContent: "center", }, leadingSlot: { width: 16, alignItems: "center", justifyContent: "center", }, trailingSlot: { marginLeft: "auto", alignItems: "center", justifyContent: "center", }, itemContent: { flexShrink: 1, }, itemText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, }, itemTextDestructive: { color: theme.colors.destructive, }, itemTextSuccess: { color: theme.colors.palette.green[500], }, itemTextSelectedAccent: { color: theme.colors.accentForeground, }, itemDescription: { marginTop: 2, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, itemDescriptionSelectedAccent: { color: theme.colors.accentForeground, opacity: 0.85, }, })); const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 };