mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 15:00:30 +03:00
338 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|