"use client"; /** * Distinguishes a tap from a press-and-hold on the same control. * * Built on Pointer Events so one implementation covers touch, mouse and pen. * Two details make it behave: * * - A hold that fires must swallow the click that follows it, or the tap * action runs straight after the hold action. * - Small finger movement during a hold is normal, so the press only * cancels once it drifts past a slop threshold — otherwise holds fail * constantly on touch. * * Timing and slop match the chat bubbles' gesture handler so a hold feels the * same everywhere in the app. */ import { useCallback, useEffect, useRef } from "react"; const LONG_PRESS_MS = 450; const LONG_PRESS_SLOP_PX = 8; export function useLongPress({ onClick, onLongPress, disabled = false, }: { onClick: () => void; onLongPress: () => void; disabled?: boolean; }) { const timer = useRef | null>(null); const start = useRef<{ x: number; y: number } | null>(null); const fired = useRef(false); const clear = useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; start.current = null; }, []); // A hold can end with the pointer outside the button, or with the component // unmounting behind whatever the hold opened; either would leak the timer. useEffect(() => clear, [clear]); const onPointerDown = (e: React.PointerEvent) => { if (disabled) return; fired.current = false; start.current = { x: e.clientX, y: e.clientY }; timer.current = setTimeout(() => { fired.current = true; clear(); onLongPress(); }, LONG_PRESS_MS); }; const onPointerMove = (e: React.PointerEvent) => { if (!start.current) return; const dx = e.clientX - start.current.x; const dy = e.clientY - start.current.y; if (Math.abs(dx) > LONG_PRESS_SLOP_PX || Math.abs(dy) > LONG_PRESS_SLOP_PX) { clear(); } }; return { onPointerDown, onPointerMove, onPointerUp: clear, onPointerCancel: clear, onPointerLeave: clear, // Suppress iOS's press-and-hold callout, which would otherwise cover the // sheet the hold just opened. onContextMenu: (e: React.MouseEvent) => { if (fired.current) e.preventDefault(); }, onClick: () => { // The hold already acted; the browser's trailing click is not a tap. if (fired.current) { fired.current = false; return; } if (!disabled) onClick(); }, }; }