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);