Compare commits
2
Commits
e078a2cc10
...
d4420905e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4420905e6 | ||
|
|
53a80adb57 |
@@ -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 { 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)} />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -510,7 +510,19 @@ function MessageNotifications() {
|
||||
// For thread replies widen the tag to room:thread so each thread
|
||||
// coalesces independently instead of clobbering the room's bucket.
|
||||
tag: threadId ? `${roomId}:${threadId}` : roomId,
|
||||
data: { path: roomPath },
|
||||
// [Gitea #203] Inline reply from the notification (Chrome/Android
|
||||
// text-input actions). The SW posts the reply itself — it holds the
|
||||
// access token for authenticated media already — so this works with
|
||||
// the tab in the background or closed. Not offered for encrypted
|
||||
// rooms: the SW cannot encrypt.
|
||||
...(encrypted
|
||||
? {}
|
||||
: {
|
||||
// `actions` is SW-notification-only and not in lib.dom's
|
||||
// NotificationOptions typing.
|
||||
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||
}),
|
||||
data: { path: roomPath, roomId, threadId: threadId || undefined },
|
||||
},
|
||||
() => {
|
||||
window.focus();
|
||||
|
||||
@@ -278,7 +278,11 @@ const isDesktopApp = (): boolean =>
|
||||
|
||||
export const showOsNotification = async (
|
||||
title: string,
|
||||
options: NotificationOptions & { data?: { path?: string } },
|
||||
options: NotificationOptions & {
|
||||
data?: { path?: string; roomId?: string; threadId?: string };
|
||||
/** Chrome/Android SW notifications: buttons, incl. text-input replies. */
|
||||
actions?: { action: string; title: string; type?: 'button' | 'text'; placeholder?: string }[];
|
||||
},
|
||||
onClick?: () => void,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
import { sendNotificationReply } from './swReply';
|
||||
|
||||
export type {};
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
@@ -147,6 +148,32 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
const data = event.notification.data ?? {};
|
||||
const path = typeof data.path === 'string' ? data.path : undefined;
|
||||
|
||||
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
||||
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
||||
const text = (event as NotificationEvent & { reply?: string }).reply?.trim();
|
||||
if (text) {
|
||||
event.waitUntil(
|
||||
sendNotificationReply(
|
||||
Array.from(sessions.values()).pop(),
|
||||
data.roomId,
|
||||
data.threadId,
|
||||
text,
|
||||
).then((ok) => {
|
||||
if (!ok) {
|
||||
// Let the user know it didn't go out, and keep their text reachable.
|
||||
return self.registration.showNotification('Reply not sent', {
|
||||
body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`,
|
||||
tag: `swreply-fail-${data.roomId}`,
|
||||
data: { path },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const windowClients = (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply';
|
||||
|
||||
test('thread replies carry the m.thread relation, room replies do not', () => {
|
||||
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
|
||||
assert.deepEqual(buildReplyContent('hi', '$root')['m.relates_to'], {
|
||||
rel_type: 'm.thread',
|
||||
event_id: '$root',
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: '$root' },
|
||||
});
|
||||
});
|
||||
|
||||
test('send URL encodes the room id against the session base URL', () => {
|
||||
assert.equal(
|
||||
replySendUrl('https://matrix.example.org/', '!r:example.org', 'tx1'),
|
||||
'https://matrix.example.org/_matrix/client/v3/rooms/!r%3Aexample.org/send/m.room.message/tx1',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchOk = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return { ok: true } as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
assert.equal(
|
||||
await sendNotificationReply(
|
||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
||||
'!r:hs',
|
||||
'$t',
|
||||
'yo',
|
||||
fetchOk,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(calls[0].init.method, 'PUT');
|
||||
assert.equal((calls[0].init.headers as Record<string, string>).Authorization, 'Bearer tok');
|
||||
assert.match(
|
||||
calls[0].url,
|
||||
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
|
||||
);
|
||||
assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t');
|
||||
|
||||
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
|
||||
assert.equal(
|
||||
await sendNotificationReply(
|
||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
||||
'!r:hs',
|
||||
undefined,
|
||||
'yo',
|
||||
fetchFail,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* [Gitea #203] Build and send an inline reply typed into a notification's
|
||||
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
|
||||
* its newest known session and the global fetch.
|
||||
*/
|
||||
export type SwReplySession = { accessToken: string; baseUrl: string };
|
||||
|
||||
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
|
||||
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
|
||||
if (threadId) {
|
||||
content['m.relates_to'] = {
|
||||
rel_type: 'm.thread',
|
||||
event_id: threadId,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: threadId },
|
||||
};
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
export const replySendUrl = (baseUrl: string, roomId: string, txnId: string): string =>
|
||||
new URL(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
|
||||
baseUrl,
|
||||
).href;
|
||||
|
||||
export async function sendNotificationReply(
|
||||
session: SwReplySession | undefined,
|
||||
roomId: string,
|
||||
threadId: string | undefined,
|
||||
text: string,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
): Promise<boolean> {
|
||||
if (!session?.accessToken || !session.baseUrl) return false;
|
||||
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(buildReplyContent(text, threadId)),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user