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 };
+}