cinny/src/app/features/room/MessageOptionsMenu.tsx
2025-06-15 23:05:16 -05:00

338 lines
10 KiB
TypeScript

import { Box, Icon, IconButton, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { MatrixClient, MatrixEvent, Relations, Room } from 'matrix-js-sdk';
import { EmojiBoard } from '../../components/emoji-board';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './message/styles.css';
import {
MessageAllReactionItem,
MessageCopyLinkItem,
MessageDeleteItem,
MessagePinItem,
MessageQuickReactions,
MessageReadReceiptItem,
MessageReportItem,
MessageSourceCodeItem,
} from './message/Message';
type BaseOptionProps = {
mEvent: MatrixEvent;
room: Room;
mx: MatrixClient;
relations: Relations | undefined;
canSendReaction: boolean | undefined;
canEdit: boolean | undefined;
canDelete: boolean | undefined;
canPinEvent: boolean | undefined;
hideReadReceipts: boolean | undefined;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string | undefined) => void;
onReplyClick: MouseEventHandler<HTMLButtonElement>;
onEditId: ((eventId?: string | undefined) => void) | undefined;
handleAddReactions: MouseEventHandler<HTMLButtonElement>;
closeMenu: () => void;
};
export function MessageDropdownMenu({
mEvent,
room,
mx,
relations,
canSendReaction,
canEdit,
canDelete,
canPinEvent,
hideReadReceipts,
onReactionToggle,
onReplyClick,
onEditId,
handleAddReactions,
closeMenu,
}: BaseOptionProps) {
return (
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId() ?? '', key, shortcode);
closeMenu();
}}
/>
)}
<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) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply
</Text>
</MenuItem>
{canEdit && 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>
Edit Message
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem room={room} eventId={mEvent.getId() ?? ''} onClose={closeMenu} />
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
</Box>
{/* Redact and Report actions */}
{((!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>
);
}
type ExtendedOptionsProps = BaseOptionProps & {
imagePackRooms: Room[] | undefined;
onActiveStateChange: React.Dispatch<React.SetStateAction<boolean>>;
menuAnchor: RectCords | undefined;
emojiBoardAnchor: RectCords | undefined;
handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement>;
handleOpenMenu: MouseEventHandler<HTMLButtonElement>;
setMenuAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
setEmojiBoardAnchor: React.Dispatch<React.SetStateAction<RectCords | undefined>>;
};
export function MessageOptionsMenu({
mEvent,
room,
mx,
relations,
imagePackRooms,
canSendReaction,
canEdit,
canDelete,
canPinEvent,
hideReadReceipts,
onReactionToggle,
onReplyClick,
onEditId,
onActiveStateChange,
closeMenu,
menuAnchor,
emojiBoardAnchor,
handleOpenEmojiBoard,
handleOpenMenu,
handleAddReactions,
setMenuAnchor,
setEmojiBoardAnchor,
}: ExtendedOptionsProps) {
useEffect(() => {
onActiveStateChange?.(!!menuAnchor || !!emojiBoardAnchor);
}, [emojiBoardAnchor, menuAnchor, onActiveStateChange]);
const eventId = mEvent.getId();
if (!eventId) return null;
return (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
{canSendReaction && (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
onReactionToggle(eventId, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(eventId, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => setEmojiBoardAnchor(undefined)}
/>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
<IconButton
onClick={onReplyClick}
data-event-id={eventId}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEdit && onEditId && (
<IconButton
onClick={() => onEditId(eventId)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt) => evt.key === 'ArrowDown',
isKeyBackward: (evt) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<MessageDropdownMenu
mEvent={mEvent}
room={room}
mx={mx}
relations={relations}
canSendReaction={canSendReaction}
canEdit={canEdit}
canDelete={canDelete}
canPinEvent={canPinEvent}
hideReadReceipts={hideReadReceipts}
onReactionToggle={onReactionToggle}
onReplyClick={onReplyClick}
onEditId={onEditId}
handleAddReactions={handleAddReactions}
closeMenu={closeMenu}
/>
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
);
}
export function BottomSheetMenu({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 1000, pointerEvents: isOpen ? 'auto' : 'none' }}
>
<div
className={classNames(css.menuBackdrop, { [css.menuBackdropOpen]: isOpen })}
onClick={onClose}
aria-hidden="true"
/>
<div
className={classNames(css.menuSheet, { [css.menuSheetOpen]: isOpen })}
role="dialog"
aria-modal="true"
>
{children}
</div>
</div>
);
}
export function MenuItemButton({
label,
onClick,
destructive = false,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<Box
as="button"
onClick={onClick}
alignItems="Center"
gap="400"
className={classNames(css.menuItem, { [css.menuItemDestructive]: destructive })}
>
<Icon src={Icons.Alphabet} size="100" />
<Text size="B300" style={{ color: 'inherit' }}>
{label}
</Text>
</Box>
);
}