feat: global long-press context menus for all mobile touch targets
Add useGlobalLongPress to ContextMenuRenderer that detects 500ms hold with <10px movement and dispatches a synthetic contextmenu event on the touched element. All existing onContextMenu handlers (spaces, channels, voice users, messages, etc.) now work on touch devices automatically. Remove per-message useLongPress integration from Message.tsx since the global handler covers it.
This commit is contained in:
@@ -16,7 +16,6 @@ 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;
|
||||||
@@ -202,36 +201,6 @@ 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();
|
||||||
@@ -555,7 +524,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onContextMenu={handleContextMenu} {...(isMobile ? longPressHandlers : {})}>
|
<div onContextMenu={handleContextMenu}>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<typeof setTimeout> | 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 ────────────────────────────────────────────────────────────
|
// ── Main renderer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ContextMenuRenderer() {
|
export function ContextMenuRenderer() {
|
||||||
@@ -532,6 +621,9 @@ export function ContextMenuRenderer() {
|
|||||||
const closeGuard = useContextMenuStore((s) => s.closeGuard);
|
const closeGuard = useContextMenuStore((s) => s.closeGuard);
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
|
|
||||||
|
// Global long-press detection — enables context menus on all touch targets
|
||||||
|
useGlobalLongPress(isMobile);
|
||||||
|
|
||||||
if (!menu) return null;
|
if (!menu) return null;
|
||||||
|
|
||||||
const filteredItems = filterMenuItems(menu.items);
|
const filteredItems = filterMenuItems(menu.items);
|
||||||
|
|||||||
Reference in New Issue
Block a user