From 80c32ae1e6e811f8e9fa12e5a251f4d23463789e Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Mon, 23 Mar 2026 14:22:38 +0100
Subject: [PATCH] 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.
---
packages/web/src/components/chat/Message.tsx | 33 +++++-
packages/web/src/hooks/useLongPress.ts | 107 +++++++++++++++++++
2 files changed, 139 insertions(+), 1 deletion(-)
create mode 100644 packages/web/src/hooks/useLongPress.ts
diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx
index a8a309cf..941ec2df 100644
--- a/packages/web/src/components/chat/Message.tsx
+++ b/packages/web/src/components/chat/Message.tsx
@@ -16,6 +16,7 @@ import { Username } from '../ui/Username';
import { EmojiPicker } from './EmojiPicker';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, resolveDisplayIdentity } from '../../utils/identity';
+import { useLongPress } from '../../hooks/useLongPress';
interface MessageProps {
message: MessageWithUser;
@@ -201,6 +202,36 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -524,7 +555,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
);
return (
-
+
{content}
);
diff --git a/packages/web/src/hooks/useLongPress.ts b/packages/web/src/hooks/useLongPress.ts
new file mode 100644
index 00000000..fd764ad9
--- /dev/null
+++ b/packages/web/src/hooks/useLongPress.ts
@@ -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
| null>(null);
+ const originRef = useRef({ 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 };
+}