diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx
index 941ec2df..a8a309cf 100644
--- a/packages/web/src/components/chat/Message.tsx
+++ b/packages/web/src/components/chat/Message.tsx
@@ -16,7 +16,6 @@ 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;
@@ -202,36 +201,6 @@ 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();
@@ -555,7 +524,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
);
return (
-
+
{content}
);
diff --git a/packages/web/src/components/ui/ContextMenuRenderer.tsx b/packages/web/src/components/ui/ContextMenuRenderer.tsx
index 456f92d8..fb9e3152 100644
--- a/packages/web/src/components/ui/ContextMenuRenderer.tsx
+++ b/packages/web/src/components/ui/ContextMenuRenderer.tsx
@@ -524,6 +524,95 @@ function MobileMenu({ items, close }: MobileMenuProps) {
);
}
+// ── Global long-press → contextmenu for mobile ──────────────────────────────
+
+/**
+ * Adds document-level touch listeners that detect long-press (500ms hold,
+ * < 10px movement) and dispatch a synthetic `contextmenu` event on the
+ * touched element. This allows ALL existing onContextMenu handlers to work
+ * on touch devices without per-component changes.
+ */
+function useGlobalLongPress(isMobile: boolean) {
+ useEffect(() => {
+ if (!isMobile) return;
+
+ let timer: ReturnType
| null = null;
+ let originX = 0;
+ let originY = 0;
+ let fired = false;
+ let activeTarget: EventTarget | null = null;
+
+ const cancel = () => {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ const onTouchStart = (e: TouchEvent) => {
+ if (e.touches.length !== 1) { cancel(); return; }
+ const touch = e.touches[0]!;
+ originX = touch.clientX;
+ originY = touch.clientY;
+ fired = false;
+ activeTarget = e.target;
+
+ timer = setTimeout(() => {
+ timer = null;
+ fired = true;
+ // Dispatch synthetic contextmenu on the original target.
+ // React's event delegation picks it up and fires onContextMenu handlers.
+ const syntheticEvent = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX: originX,
+ clientY: originY,
+ screenX: originX,
+ screenY: originY,
+ });
+ if (activeTarget) {
+ activeTarget.dispatchEvent(syntheticEvent);
+ }
+ }, 500);
+ };
+
+ const onTouchMove = (e: TouchEvent) => {
+ if (timer === null) return;
+ const touch = e.touches[0];
+ if (!touch) return;
+ if (Math.hypot(touch.clientX - originX, touch.clientY - originY) > 10) cancel();
+ };
+
+ const onTouchEnd = () => {
+ cancel();
+ if (fired) {
+ const suppressClick = (ev: MouseEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ };
+ document.addEventListener('click', suppressClick, { capture: true, once: true });
+ setTimeout(() => {
+ document.removeEventListener('click', suppressClick, { capture: true });
+ }, 500);
+ fired = false;
+ }
+ };
+
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
+ document.addEventListener('touchmove', onTouchMove, { passive: true });
+ document.addEventListener('touchend', onTouchEnd, { passive: true });
+ document.addEventListener('touchcancel', onTouchEnd, { passive: true });
+
+ return () => {
+ cancel();
+ document.removeEventListener('touchstart', onTouchStart);
+ document.removeEventListener('touchmove', onTouchMove);
+ document.removeEventListener('touchend', onTouchEnd);
+ document.removeEventListener('touchcancel', onTouchEnd);
+ };
+ }, [isMobile]);
+}
+
// ── Main renderer ────────────────────────────────────────────────────────────
export function ContextMenuRenderer() {
@@ -532,6 +621,9 @@ export function ContextMenuRenderer() {
const closeGuard = useContextMenuStore((s) => s.closeGuard);
const isMobile = useUIStore((s) => s.isMobile);
+ // Global long-press detection — enables context menus on all touch targets
+ useGlobalLongPress(isMobile);
+
if (!menu) return null;
const filteredItems = filterMenuItems(menu.items);