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';
|
||||||
@@ -94,6 +94,8 @@ import colorMXID from '../../../../util/colorMXID';
|
|||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||||
import { RemindMeDialog } from './RemindMeDialog';
|
import { RemindMeDialog } from './RemindMeDialog';
|
||||||
|
import { useLongPress } from '../../../hooks/useLongPress';
|
||||||
|
import { ActionSheet } from '../../../components/action-sheet';
|
||||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||||
import { PresenceRingAvatar } from '../../../components/presence';
|
import { PresenceRingAvatar } from '../../../components/presence';
|
||||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||||
@@ -1066,11 +1068,33 @@ export const Message = React.memo(
|
|||||||
</Box>
|
</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) => {
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
||||||
const tag = (evt.target as any).tagName;
|
const tag = (evt.target as any).tagName;
|
||||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
if (coarsePointer) {
|
||||||
|
// Android fires contextmenu for the same long-press we already handled.
|
||||||
|
if (!suppressContextMenu.current) setSheetOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMenuAnchor({
|
setMenuAnchor({
|
||||||
x: evt.clientX,
|
x: evt.clientX,
|
||||||
y: evt.clientY,
|
y: evt.clientY,
|
||||||
@@ -1086,6 +1110,7 @@ export const Message = React.memo(
|
|||||||
|
|
||||||
const closeMenu = () => {
|
const closeMenu = () => {
|
||||||
setMenuAnchor(undefined);
|
setMenuAnchor(undefined);
|
||||||
|
setSheetOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
@@ -1105,6 +1130,201 @@ export const Message = React.memo(
|
|||||||
|
|
||||||
const isThreadedMessage = mEvent.threadRootId !== undefined;
|
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 (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className, {
|
className={classNames(css.MessageBase, className, {
|
||||||
@@ -1234,274 +1454,7 @@ export const Message = React.memo(
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
{menuJSX}
|
||||||
<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>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1522,21 +1475,74 @@ export const Message = React.memo(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messageLayout === MessageLayout.Compact && (
|
{messageLayout === MessageLayout.Compact && (
|
||||||
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
<CompactLayout
|
||||||
|
before={headerJSX}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
{...longPressHandlers}
|
||||||
|
>
|
||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</CompactLayout>
|
</CompactLayout>
|
||||||
)}
|
)}
|
||||||
{messageLayout === MessageLayout.Bubble && (
|
{messageLayout === MessageLayout.Bubble && (
|
||||||
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
<BubbleLayout
|
||||||
|
before={avatarJSX}
|
||||||
|
header={headerJSX}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
{...longPressHandlers}
|
||||||
|
>
|
||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</BubbleLayout>
|
</BubbleLayout>
|
||||||
)}
|
)}
|
||||||
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
||||||
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
<ModernLayout
|
||||||
|
before={avatarJSX}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
{...longPressHandlers}
|
||||||
|
>
|
||||||
{headerJSX}
|
{headerJSX}
|
||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</ModernLayout>
|
</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 && (
|
{forwardOpen && (
|
||||||
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
|
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user