feat(mobile): long-press a message opens a bottom sheet of actions (#166)
On coarse-pointer devices the hover action bar is unreachable and iOS Safari has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement, cancelled by scroll/lift) now opens an ActionSheet — slides up from the bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick reactions row + 'Add reaction' on top and the same folds Menu the desktop popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu echo of the same press is dropped; the press-induced text/image selection is cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is unchanged (right-click → anchored popout). Verified with Playwright touch emulation (Pixel 7, CDP touch events): long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark / Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses; a scroll gesture does not open it; tapping Reply closes it and arms the composer. Still wants a real iPhone + Android pass before it's called done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMediaQuery } from './useMediaQuery';
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const MOVE_TOLERANCE_PX = 10;
|
||||
|
||||
/**
|
||||
* [Gitea #166] Long-press detection for touch screens. Fires `onLongPress`
|
||||
* with the finger's viewport position after 450 ms of holding still (< 10 px
|
||||
* of movement); a scroll or a lift cancels it. Android also fires
|
||||
* `contextmenu` for a long-press — the returned `suppressContextMenu` ref is
|
||||
* true briefly after we fired so the caller can drop that duplicate. Handlers
|
||||
* are no-ops when the device has no coarse pointer.
|
||||
*/
|
||||
export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
||||
const coarse = useMediaQuery('(pointer: coarse)');
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const start = useRef<{ x: number; y: number } | null>(null);
|
||||
const fired = useRef(false);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
||||
timer.current = undefined;
|
||||
start.current = null;
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(evt: React.TouchEvent) => {
|
||||
if (!coarse || evt.touches.length !== 1) return;
|
||||
const t = evt.touches[0];
|
||||
start.current = { x: t.clientX, y: t.clientY };
|
||||
fired.current = false;
|
||||
timer.current = window.setTimeout(() => {
|
||||
const s = start.current;
|
||||
clear();
|
||||
if (!s) return;
|
||||
fired.current = true;
|
||||
window.setTimeout(() => {
|
||||
fired.current = false;
|
||||
}, 1000);
|
||||
onLongPress(s.x, s.y);
|
||||
}, LONG_PRESS_MS);
|
||||
},
|
||||
[coarse, clear, onLongPress],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(evt: React.TouchEvent) => {
|
||||
const s = start.current;
|
||||
if (!s) return;
|
||||
const t = evt.touches[0];
|
||||
if (
|
||||
!t ||
|
||||
Math.abs(t.clientX - s.x) > MOVE_TOLERANCE_PX ||
|
||||
Math.abs(t.clientY - s.y) > MOVE_TOLERANCE_PX
|
||||
) {
|
||||
clear();
|
||||
}
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
// Lifting the finger after a long-press would synthesise mousedown/click at
|
||||
// that point — on whatever the sheet just put there. Swallow it.
|
||||
const onTouchEnd = useCallback(
|
||||
(evt: React.TouchEvent) => {
|
||||
if (fired.current && evt.cancelable) evt.preventDefault();
|
||||
clear();
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
return {
|
||||
coarse,
|
||||
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
|
||||
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
||||
suppressContextMenu: fired,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user