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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ActionSheet';
|
||||
Reference in New Issue
Block a user