feat: add long-press context menus for mobile messages

Create useLongPress hook with 500ms delay, 10px movement cancellation
(Euclidean distance), and ghost click suppression. Integrate into
Message component to trigger the existing context menu bottom sheet
on touch devices.
This commit is contained in:
Jannis Braun
2026-03-23 14:22:38 +01:00
parent 876326e7b7
commit 80c32ae1e6
2 changed files with 139 additions and 1 deletions
+32 -1
View File
@@ -16,6 +16,7 @@ import { Username } from '../ui/Username';
import { EmojiPicker } from './EmojiPicker'; import { EmojiPicker } from './EmojiPicker';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; import { isSelf, resolveDisplayIdentity } from '../../utils/identity';
import { useLongPress } from '../../hooks/useLongPress';
interface MessageProps { interface MessageProps {
message: MessageWithUser; message: MessageWithUser;
@@ -201,6 +202,36 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
useContextMenuStore.getState().open({ x: e.clientX, y: e.clientY }, items); useContextMenuStore.getState().open({ x: e.clientX, y: e.clientY }, items);
}; };
const isMobile = useUIStore((s) => s.isMobile);
const longPressHandlers = useLongPress((position) => {
const selectedText = window.getSelection()?.toString() ?? '';
const items = buildMessageMenuItems({
message,
selectedText,
previousMessageId,
isAuthor,
isDm: isDmMessage,
canAddReactions,
canSendMessages,
canManageMessages,
onReply: () => setReplyTo(message),
onEdit: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
onDelete: () => deleteMessage(message.id, channelKey),
onReaction: (emoji: string) => toggleReaction(emoji),
onOpenEmojiPicker: () => {
useContextMenuStore.getState().close();
setShowReactionPicker(true);
},
onMarkUnread: (msgId: string) => markUnread(channelKey, msgId),
});
if (items.length === 0) return;
useContextMenuStore.getState().open({ x: position.clientX, y: position.clientY }, items);
});
const handleEditSubmit = async (e: React.KeyboardEvent) => { const handleEditSubmit = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -524,7 +555,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
); );
return ( return (
<div onContextMenu={handleContextMenu}> <div onContextMenu={handleContextMenu} {...(isMobile ? longPressHandlers : {})}>
{content} {content}
</div> </div>
); );
+107
View File
@@ -0,0 +1,107 @@
import React, { useRef, useCallback, useEffect } from 'react';
interface UseLongPressOptions {
delay?: number;
moveThreshold?: number;
}
interface LongPressPosition {
clientX: number;
clientY: number;
}
type LongPressCallback = (position: LongPressPosition) => void;
interface LongPressHandlers {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onTouchCancel: (e: React.TouchEvent) => void;
}
export function useLongPress(
callback: LongPressCallback,
options?: UseLongPressOptions,
): LongPressHandlers {
const delay = options?.delay ?? 500;
const moveThreshold = options?.moveThreshold ?? 10;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const originRef = useRef<LongPressPosition>({ clientX: 0, clientY: 0 });
const firedRef = useRef(false);
const callbackRef = useRef(callback);
// Keep callback ref fresh to avoid stale closures
callbackRef.current = callback;
// Clean up timer on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
const cancel = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
firedRef.current = false;
originRef.current = { clientX: touch.clientX, clientY: touch.clientY };
timerRef.current = setTimeout(() => {
timerRef.current = null;
firedRef.current = true;
callbackRef.current(originRef.current);
}, delay);
}, [delay]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (timerRef.current === null) return;
const touch = e.touches[0];
if (!touch) return;
const dx = touch.clientX - originRef.current.clientX;
const dy = touch.clientY - originRef.current.clientY;
const distance = Math.hypot(dx, dy);
if (distance > moveThreshold) {
cancel();
}
}, [moveThreshold, cancel]);
const onTouchEnd = useCallback((_e: React.TouchEvent) => {
cancel();
// If the long press fired, suppress the subsequent click event
if (firedRef.current) {
// The ghost click fires ~300ms after touchend on some browsers.
// We set a capture-phase click listener that prevents it.
const suppressClick = (clickEvent: MouseEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
};
document.addEventListener('click', suppressClick, { capture: true, once: true });
// Safety: remove the listener after 500ms in case no click fires
setTimeout(() => {
document.removeEventListener('click', suppressClick, { capture: true });
}, 500);
firedRef.current = false;
}
}, [cancel]);
const onTouchCancel = useCallback((_e: React.TouchEvent) => {
cancel();
firedRef.current = false;
}, [cancel]);
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
}