"use client"; /** * Owns every pointer gesture on a chat bubble: swipe right to reply, and * long-press (or right-click) to open the context menu. * * They live in ONE component because they share a pointer stream and must * arbitrate between themselves. Two independent listeners would both claim the * same press — you would get a menu in the middle of a swipe, or a swipe that * never fires because the menu opened first. * * Swipe-to-reply behaves as before: * * Details that make the gesture feel right rather than fight the page: * * • Only horizontal drags are captured. A gesture that starts out mostly * vertical is released back to the scroller immediately, so swiping never * steals a scroll. * • Rightward only, with resistance past the trigger point, so the bubble * can't be dragged across the screen. * • The reply fires on release, not on crossing the threshold, so a drag can * still be cancelled by sliding back. * * Pointer Events cover touch and mouse, so the same code gives desktop a * click-drag; the context menu in a later phase adds a non-gesture route. */ import { useCallback, useRef, useState } from "react"; import { Reply } from "lucide-react"; import { cn } from "@/lib/utils"; /** Drag distance that arms the reply. */ const TRIGGER_PX = 56; /** Hard stop, so the bubble never travels far. */ const MAX_PX = 80; /** Horizontal movement needed before we claim the gesture from the scroller. */ const CLAIM_PX = 10; /** Hold time before a stationary press becomes a context menu. */ const LONG_PRESS_MS = 450; /** Movement that cancels a long press — a drag is not a hold. */ const LONG_PRESS_SLOP_PX = 8; export function MessageGestures({ onReply, onMenu, disabled = false, children, }: { onReply: () => void; /** Opens the context menu at the given viewport point. */ onMenu: (point: { x: number; y: number }) => void; disabled?: boolean; children: React.ReactNode; }) { const [offset, setOffset] = useState(0); const [dragging, setDragging] = useState(false); const start = useRef<{ x: number; y: number } | null>(null); // null = undecided, true = ours, false = the scroller's const claimed = useRef(null); const holdTimer = useRef | null>(null); // Set once a long press fires, so the release doesn't also trigger a reply. const consumed = useRef(false); const cancelHold = useCallback(() => { if (holdTimer.current) { clearTimeout(holdTimer.current); holdTimer.current = null; } }, []); const reset = useCallback(() => { cancelHold(); start.current = null; claimed.current = null; setDragging(false); setOffset(0); }, [cancelHold]); const onPointerDown = (e: React.PointerEvent) => { if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return; start.current = { x: e.clientX, y: e.clientY }; claimed.current = null; consumed.current = false; // A stationary hold opens the menu. Touch only: a mouse has right-click, // and making a held left-click open a menu would surprise desktop users. if (e.pointerType !== "mouse") { const point = { x: e.clientX, y: e.clientY }; cancelHold(); holdTimer.current = setTimeout(() => { consumed.current = true; reset(); onMenu(point); }, LONG_PRESS_MS); } }; const onPointerMove = (e: React.PointerEvent) => { if (!start.current || disabled) return; const dx = e.clientX - start.current.x; const dy = e.clientY - start.current.y; // Any real movement means this is a drag, not a hold. if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) { cancelHold(); } if (claimed.current === null) { if (Math.abs(dx) < CLAIM_PX && Math.abs(dy) < CLAIM_PX) return; // Vertical intent, or a leftward drag: hand it back to the scroller and // stay out of the way for the rest of this gesture. claimed.current = Math.abs(dx) > Math.abs(dy) && dx > 0; if (!claimed.current) { start.current = null; return; } e.currentTarget.setPointerCapture?.(e.pointerId); setDragging(true); } // Resistance past the trigger point. const travel = dx <= TRIGGER_PX ? dx : TRIGGER_PX + (dx - TRIGGER_PX) * 0.25; setOffset(Math.max(0, Math.min(travel, MAX_PX))); }; const onPointerUp = () => { cancelHold(); // A long press already acted; releasing must not also fire a reply. if (!consumed.current && claimed.current && offset >= TRIGGER_PX) onReply(); reset(); }; const armed = offset >= TRIGGER_PX; return (
{ if (disabled) return; e.preventDefault(); cancelHold(); onMenu({ x: e.clientX, y: e.clientY }); }} // Let the browser own vertical panning; we only ever take horizontal. style={{ touchAction: "pan-y" }} > 8 ? "opacity-100" : "opacity-0", )} >
{children}
); }