feat(mobile): long-press a message opens a bottom sheet of actions (#166)

On coarse-pointer devices the hover action bar is unreachable and iOS Safari
has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement,
cancelled by scroll/lift) now opens an ActionSheet — slides up from the
bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick
reactions row + 'Add reaction' on top and the same folds Menu the desktop
popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu
echo of the same press is dropped; the press-induced text/image selection is
cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu
item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is
unchanged (right-click → anchored popout).

Verified with Playwright touch emulation (Pixel 7, CDP touch events):
long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark /
Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses;
a scroll gesture does not open it; tapping Reply closes it and arms the
composer. Still wants a real iPhone + Android pass before it's called done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 13:59:03 -04:00
co-authored by Claude Opus 5
parent e078a2cc10
commit 53a80adb57
4 changed files with 457 additions and 271 deletions
@@ -0,0 +1,100 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Overlay, OverlayBackdrop, config, color, toRem } from 'folds';
import { stopPropagation } from '../../utils/keyboard';
type ActionSheetProps = {
open: boolean;
onClose: () => void;
'aria-label'?: string;
children: ReactNode;
};
/**
* [Gitea #166] A bottom sheet for touch screens: slides up from the bottom,
* swipe-down or backdrop tap dismisses. Content is whatever the caller
* renders (quick reactions + a folds Menu, for message actions).
*/
export function ActionSheet({
open,
onClose,
children,
'aria-label': ariaLabel,
}: ActionSheetProps) {
const [dragY, setDragY] = useState(0);
const startY = useRef<number | null>(null);
// The long-press that opened us ends with a touchend/click that lands
// "outside" the sheet — ignore outside clicks for the first moments.
const openedAt = useRef(Date.now());
const onTouchStart = useCallback((evt: React.TouchEvent) => {
startY.current = evt.touches[0]?.clientY ?? null;
}, []);
const onTouchMove = useCallback((evt: React.TouchEvent) => {
if (startY.current === null) return;
const dy = (evt.touches[0]?.clientY ?? startY.current) - startY.current;
setDragY(Math.max(0, dy));
}, []);
const onTouchEnd = useCallback(() => {
const dismiss = dragY > 80;
startY.current = null;
setDragY(0);
if (dismiss) onClose();
}, [dragY, onClose]);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: () => Date.now() - openedAt.current > 600,
escapeDeactivates: stopPropagation,
}}
>
<Box
role="dialog"
aria-modal
aria-label={ariaLabel}
direction="Column"
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75vh',
background: color.Surface.Container,
color: color.Surface.OnContainer,
borderTopLeftRadius: toRem(16),
borderTopRightRadius: toRem(16),
boxShadow: '0 -8px 32px rgba(0,0,0,0.35)',
paddingBottom: 'env(safe-area-inset-bottom)',
transform: dragY ? `translateY(${dragY}px)` : undefined,
transition: dragY ? undefined : 'transform 120ms ease-out',
overflow: 'hidden',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
<Box justifyContent="Center" shrink="No" style={{ padding: config.space.S200 }}>
<div
aria-hidden
style={{
width: toRem(36),
height: toRem(4),
borderRadius: toRem(2),
background: color.Surface.ContainerLine,
}}
/>
</Box>
<Box direction="Column" style={{ overflowY: 'auto' }}>
{children}
</Box>
</Box>
</FocusTrap>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './ActionSheet';
+277 -271
View File
@@ -94,6 +94,8 @@ import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
import { RemindMeDialog } from './RemindMeDialog';
import { useLongPress } from '../../../hooks/useLongPress';
import { ActionSheet } from '../../../components/action-sheet';
import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
@@ -1066,11 +1068,33 @@ export const Message = React.memo(
</Box>
);
// [Gitea #166] Touch: a long-press (or Android's contextmenu) opens the
// actions as a bottom sheet instead of a hover bar + anchored popout.
const [sheetOpen, setSheetOpen] = useState(false);
const {
coarse: coarsePointer,
handlers: longPressHandlers,
suppressContextMenu,
} = useLongPress(
useCallback(() => {
if (edit) return;
// The press itself starts a text/image selection on touch browsers;
// that is a by-product, not the user's intent — drop it.
window.getSelection()?.removeAllRanges();
setSheetOpen(true);
}, [edit]),
);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as any).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
if (coarsePointer) {
// Android fires contextmenu for the same long-press we already handled.
if (!suppressContextMenu.current) setSheetOpen(true);
return;
}
setMenuAnchor({
x: evt.clientX,
y: evt.clientY,
@@ -1086,6 +1110,7 @@ export const Message = React.memo(
const closeMenu = () => {
setMenuAnchor(undefined);
setSheetOpen(false);
};
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -1105,6 +1130,201 @@ export const Message = React.memo(
const isThreadedMessage = mEvent.threadRootId !== undefined;
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
const menuJSX = (
<Menu>
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Add Reaction
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem room={room} relations={relations} onClose={closeMenu} />
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply
</Text>
</MenuItem>
{!mEvent.isRedacted() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ArrowRight} />}
radii="300"
onClick={() => {
setForwardOpen(true);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Forward
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Star} filled={isBookmarked(mEvent.getId()!)} />}
radii="300"
onClick={() => {
const eventId = mEvent.getId()!;
if (isBookmarked(eventId)) {
removeBookmark(eventId);
} else {
const content = mEvent.getContent();
const body: string = (content?.body as string | undefined) ?? '';
// For E2EE rooms useBookmarks strips the text
// fields before persisting (account data is
// server-readable); the panel resolves them live.
addBookmark({
roomId: room.roomId,
eventId,
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
senderName: senderDisplayName,
});
}
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{isBookmarked(mEvent.getId()!) ? 'Remove Bookmark' : 'Bookmark Message'}
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Clock} />}
radii="300"
onClick={() => {
setRemindOpen(true);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Remind Me
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply in Thread
</Text>
</MenuItem>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={() => {
onEditId(mEvent.getId());
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
)}
{showDeveloperTools && (
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLotusLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
</Box>
{(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Retry Send
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Cancel Message
</Text>
</MenuItem>
</Box>
</>
)}
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
</>
)}
</Menu>
);
return (
<MessageBase
className={classNames(css.MessageBase, className, {
@@ -1234,274 +1454,7 @@ export const Message = React.memo(
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Add Reaction
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem
room={room}
relations={relations}
onClose={closeMenu}
/>
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply
</Text>
</MenuItem>
{!mEvent.isRedacted() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ArrowRight} />}
radii="300"
onClick={() => {
setForwardOpen(true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Forward
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={
<Icon
size="100"
src={Icons.Star}
filled={isBookmarked(mEvent.getId()!)}
/>
}
radii="300"
onClick={() => {
const eventId = mEvent.getId()!;
if (isBookmarked(eventId)) {
removeBookmark(eventId);
} else {
const content = mEvent.getContent();
const body: string =
(content?.body as string | undefined) ?? '';
// For E2EE rooms useBookmarks strips the text
// fields before persisting (account data is
// server-readable); the panel resolves them live.
addBookmark({
roomId: room.roomId,
eventId,
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
senderName: senderDisplayName,
});
}
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{isBookmarked(mEvent.getId()!)
? 'Remove Bookmark'
: 'Bookmark Message'}
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Clock} />}
radii="300"
onClick={() => {
setRemindOpen(true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Remind Me
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply in Thread
</Text>
</MenuItem>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={() => {
onEditId(mEvent.getId());
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
)}
{showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLotusLinkItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
{(mEvent.status === EventStatus.NOT_SENT ||
mEvent.status === EventStatus.CANCELLED) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Retry Send
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Cancel Message
</Text>
</MenuItem>
</Box>
</>
)}
{((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
</Box>
</>
)}
</Menu>
{menuJSX}
</FocusTrap>
}
>
@@ -1522,21 +1475,74 @@ export const Message = React.memo(
</div>
)}
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
<CompactLayout
before={headerJSX}
onContextMenu={handleContextMenu}
{...longPressHandlers}
>
{msgContentJSX}
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
<BubbleLayout
before={avatarJSX}
header={headerJSX}
onContextMenu={handleContextMenu}
{...longPressHandlers}
>
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
<ModernLayout
before={avatarJSX}
onContextMenu={handleContextMenu}
{...longPressHandlers}
>
{headerJSX}
{msgContentJSX}
</ModernLayout>
)}
{sheetOpen && (
<ActionSheet open onClose={() => setSheetOpen(false)} aria-label="Message actions">
{canSendReaction && (
<Box alignItems="Center" justifyContent="Center" gap="200">
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode);
setSheetOpen(false);
}}
/>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
aria-label="Add reaction"
onClick={() => {
setSheetOpen(false);
// the emoji board anchors to the message; open it after the sheet is gone
setTimeout(() => {
const el = document.querySelector<HTMLElement>(
`[data-message-id="${mEvent.getId()}"]`,
);
setEmojiBoardAnchor(
el?.getBoundingClientRect() ?? {
x: 0,
y: window.innerHeight / 2,
width: window.innerWidth,
height: 0,
},
);
}, 50);
}}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</Box>
)}
{menuJSX}
</ActionSheet>
)}
{forwardOpen && (
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
)}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useRef } from 'react';
import { useMediaQuery } from './useMediaQuery';
const LONG_PRESS_MS = 450;
const MOVE_TOLERANCE_PX = 10;
/**
* [Gitea #166] Long-press detection for touch screens. Fires `onLongPress`
* with the finger's viewport position after 450 ms of holding still (< 10 px
* of movement); a scroll or a lift cancels it. Android also fires
* `contextmenu` for a long-press — the returned `suppressContextMenu` ref is
* true briefly after we fired so the caller can drop that duplicate. Handlers
* are no-ops when the device has no coarse pointer.
*/
export function useLongPress(onLongPress: (x: number, y: number) => void) {
const coarse = useMediaQuery('(pointer: coarse)');
const timer = useRef<number | undefined>(undefined);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
const clear = useCallback(() => {
if (timer.current !== undefined) window.clearTimeout(timer.current);
timer.current = undefined;
start.current = null;
}, []);
const onTouchStart = useCallback(
(evt: React.TouchEvent) => {
if (!coarse || evt.touches.length !== 1) return;
const t = evt.touches[0];
start.current = { x: t.clientX, y: t.clientY };
fired.current = false;
timer.current = window.setTimeout(() => {
const s = start.current;
clear();
if (!s) return;
fired.current = true;
window.setTimeout(() => {
fired.current = false;
}, 1000);
onLongPress(s.x, s.y);
}, LONG_PRESS_MS);
},
[coarse, clear, onLongPress],
);
const onTouchMove = useCallback(
(evt: React.TouchEvent) => {
const s = start.current;
if (!s) return;
const t = evt.touches[0];
if (
!t ||
Math.abs(t.clientX - s.x) > MOVE_TOLERANCE_PX ||
Math.abs(t.clientY - s.y) > MOVE_TOLERANCE_PX
) {
clear();
}
},
[clear],
);
// Lifting the finger after a long-press would synthesise mousedown/click at
// that point — on whatever the sheet just put there. Swallow it.
const onTouchEnd = useCallback(
(evt: React.TouchEvent) => {
if (fired.current && evt.cancelable) evt.preventDefault();
clear();
},
[clear],
);
return {
coarse,
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
suppressContextMenu: fired,
};
}