diff --git a/src/app/components/action-sheet/ActionSheet.tsx b/src/app/components/action-sheet/ActionSheet.tsx new file mode 100644 index 000000000..7ffa3731a --- /dev/null +++ b/src/app/components/action-sheet/ActionSheet.tsx @@ -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(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 ( + }> + Date.now() - openedAt.current > 600, + escapeDeactivates: stopPropagation, + }} + > + + +
+ + + {children} + + + + + ); +} diff --git a/src/app/components/action-sheet/index.ts b/src/app/components/action-sheet/index.ts new file mode 100644 index 000000000..0a796d013 --- /dev/null +++ b/src/app/components/action-sheet/index.ts @@ -0,0 +1 @@ +export * from './ActionSheet'; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index f913fe0f9..b11ad4c02 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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( ); + // [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 = (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 = (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 = ( + + + {canSendReaction && ( + } + radii="300" + onClick={handleAddReactions} + > + + Add Reaction + + + )} + {relations && ( + + )} + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt); + closeMenu(); + }} + > + + Reply + + + {!mEvent.isRedacted() && ( + } + radii="300" + onClick={() => { + setForwardOpen(true); + closeMenu(); + }} + > + + Forward + + + )} + {!mEvent.isRedacted() && 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(); + }} + > + + {isBookmarked(mEvent.getId()!) ? 'Remove Bookmark' : 'Bookmark Message'} + + + )} + {!mEvent.isRedacted() && mEvent.getId() && ( + } + radii="300" + onClick={() => { + setRemindOpen(true); + closeMenu(); + }} + > + + Remind Me + + + )} + {!isThreadedMessage && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={(evt: any) => { + onReplyClick(evt, true); + closeMenu(); + }} + > + + Reply in Thread + + + )} + {canEditEventOrCaption(mx, mEvent) && onEditId && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={() => { + onEditId(mEvent.getId()); + closeMenu(); + }} + > + + {canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'} + + + )} + {!hideReadReceipts && ( + + )} + {showDeveloperTools && ( + + )} + + + + + {canPinEvent && } + + {(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) && ( + <> + + + } + radii="300" + onClick={() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mx as any).resendEvent(mEvent, room); + closeMenu(); + }} + > + + Retry Send + + + } + radii="300" + onClick={() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mx as any).cancelPendingEvent(mEvent); + closeMenu(); + }} + > + + Cancel Message + + + + + )} + {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} + + ); + return ( - - - {canSendReaction && ( - } - radii="300" - onClick={handleAddReactions} - > - - Add Reaction - - - )} - {relations && ( - - )} - } - radii="300" - data-event-id={mEvent.getId()} - onClick={(evt: any) => { - onReplyClick(evt); - closeMenu(); - }} - > - - Reply - - - {!mEvent.isRedacted() && ( - } - radii="300" - onClick={() => { - setForwardOpen(true); - closeMenu(); - }} - > - - Forward - - - )} - {!mEvent.isRedacted() && 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(); - }} - > - - {isBookmarked(mEvent.getId()!) - ? 'Remove Bookmark' - : 'Bookmark Message'} - - - )} - {!mEvent.isRedacted() && mEvent.getId() && ( - } - radii="300" - onClick={() => { - setRemindOpen(true); - closeMenu(); - }} - > - - Remind Me - - - )} - {!isThreadedMessage && ( - } - radii="300" - data-event-id={mEvent.getId()} - onClick={(evt: any) => { - onReplyClick(evt, true); - closeMenu(); - }} - > - - Reply in Thread - - - )} - {canEditEventOrCaption(mx, mEvent) && onEditId && ( - } - radii="300" - data-event-id={mEvent.getId()} - onClick={() => { - onEditId(mEvent.getId()); - closeMenu(); - }} - > - - {canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'} - - - )} - {!hideReadReceipts && ( - - )} - {showDeveloperTools && ( - - )} - - - - - {canPinEvent && ( - - )} - - {(mEvent.status === EventStatus.NOT_SENT || - mEvent.status === EventStatus.CANCELLED) && ( - <> - - - } - radii="300" - onClick={() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mx as any).resendEvent(mEvent, room); - closeMenu(); - }} - > - - Retry Send - - - } - radii="300" - onClick={() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mx as any).cancelPendingEvent(mEvent); - closeMenu(); - }} - > - - Cancel Message - - - - - )} - {((!mEvent.isRedacted() && canDelete) || - mEvent.getSender() !== mx.getUserId()) && ( - <> - - - {!mEvent.isRedacted() && canDelete && ( - - )} - {mEvent.getSender() !== mx.getUserId() && ( - - )} - - - )} - + {menuJSX} } > @@ -1522,21 +1475,74 @@ export const Message = React.memo(
)} {messageLayout === MessageLayout.Compact && ( - + {msgContentJSX} )} {messageLayout === MessageLayout.Bubble && ( - + {msgContentJSX} )} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( - + {headerJSX} {msgContentJSX} )} + {sheetOpen && ( + setSheetOpen(false)} aria-label="Message actions"> + {canSendReaction && ( + + { + onReactionToggle(mEvent.getId()!, key, shortcode); + setSheetOpen(false); + }} + /> + { + setSheetOpen(false); + // the emoji board anchors to the message; open it after the sheet is gone + setTimeout(() => { + const el = document.querySelector( + `[data-message-id="${mEvent.getId()}"]`, + ); + setEmojiBoardAnchor( + el?.getBoundingClientRect() ?? { + x: 0, + y: window.innerHeight / 2, + width: window.innerWidth, + height: 0, + }, + ); + }, 50); + }} + > + + + + )} + {menuJSX} + + )} {forwardOpen && ( setForwardOpen(false)} /> )} diff --git a/src/app/hooks/useLongPress.ts b/src/app/hooks/useLongPress.ts new file mode 100644 index 000000000..1a13b81ff --- /dev/null +++ b/src/app/hooks/useLongPress.ts @@ -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(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, + }; +}