"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; /** Longest gap between two taps still read as a double tap. */ const DOUBLE_TAP_MS = 300; /** How far the second tap may land from the first. */ const DOUBLE_TAP_SLOP_PX = 24; export function MessageGestures({ onReply, onMenu, onDoubleTap, disabled = false, children, }: { onReply: () => void; /** Opens the context menu at the given viewport point. */ onMenu: (point: { x: number; y: number }) => void; /** Two quick taps in the same spot — sends a heart. */ onDoubleTap?: () => 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); // When and where the last tap landed, for pairing it with the next one. const lastTap = useRef<{ t: number; x: number; y: number } | null>(null); 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 = (e: React.PointerEvent) => { cancelHold(); // A long press already acted; releasing must not also fire a reply. if (!consumed.current && claimed.current && offset >= TRIGGER_PX) { onReply(); lastTap.current = null; } else if (!consumed.current && !claimed.current && onDoubleTap) { // A stationary tap. Paired with a recent nearby one, it is a double tap. // Tracked here rather than with onDoubleClick so touch and mouse behave // the same, and so a swipe or a long press can never count as a tap. const now = Date.now(); const prev = lastTap.current; const near = prev !== null && Math.abs(e.clientX - prev.x) < DOUBLE_TAP_SLOP_PX && Math.abs(e.clientY - prev.y) < DOUBLE_TAP_SLOP_PX; if (prev && near && now - prev.t < DOUBLE_TAP_MS) { lastTap.current = null; onDoubleTap(); } else { lastTap.current = { t: now, x: e.clientX, y: e.clientY }; } } 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}
); }