Files
cinny/src/app/features/room/message/Message.tsx
T
jaredandClaude Opus 5 4d4a76214a
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
refactor(time): one timestamp formatter honouring the clock/date settings (#139)
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.

utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.

Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:28:47 -04:00

1728 lines
58 KiB
TypeScript

/* eslint-disable react-hooks/rules-of-hooks */
import {
Avatar,
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
IconSrc,
Icons,
Input,
Line,
Menu,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
RectCords,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import React, {
FormEventHandler,
MouseEventHandler,
ReactNode,
useCallback,
useRef,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
BubbleLayout,
CompactLayout,
MessageBase,
ModernLayout,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import {
canEditCaption,
canEditEventOrCaption,
getEventEdits,
getMemberAvatarMxc,
getMemberName,
sendStateEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
import { MentionHighlightPulse } from '../../../components/message/layout/layout.css';
import { EventReaders } from '../../../components/event-readers';
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
import { useReadPositions } from '../ReadPositionsContext';
import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
import { UserAvatar } from '../../../components/user-avatar';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
import { getOriginBaseUrl } from '../../../pages/pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
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';
// Delivery status indicator for own messages
function DeliveryStatus({
status,
lotusTerminal,
}: {
status: string | null;
lotusTerminal: boolean;
}) {
if (status === null) return null; // confirmed by server — read receipts take over
let iconSrc: IconSrc;
let label: string;
let colorStyle: string;
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
iconSrc = Icons.Cross;
label = 'Failed to send';
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
} else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued';
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
: color.Secondary.Main;
} else {
iconSrc = Icons.Check;
label = 'Sent';
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
: color.Secondary.Main;
}
return (
<Box
as="span"
aria-label={label}
title={label}
style={{
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && status === EventStatus.NOT_SENT
? { textShadow: 'var(--lt-glow-red)' }
: {}),
}}
>
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
</Box>
);
}
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
type MessageQuickReactionsProps = {
onReaction: ReactionHandler;
};
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 3);
if (recentEmojis.length === 0) return null;
return (
<>
<Box
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
gap="200"
{...props}
ref={ref}
>
{recentEmojis.map((emoji) => (
<IconButton
key={emoji.unicode}
className={css.MessageQuickReaction}
size="300"
variant="SurfaceVariant"
radii="Pill"
title={emoji.shortcode}
aria-label={emoji.shortcode}
onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
>
<Text size="T500">{emoji.unicode}</Text>
</IconButton>
))}
</Box>
</>
);
},
);
export const MessageAllReactionItem = as<
'button',
{
room: Room;
relations: Relations;
onClose?: () => void;
}
>(({ room, relations, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay
onContextMenu={(evt: any) => {
evt.stopPropagation();
}}
open={open}
backdrop={<OverlayBackdrop />}
>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => handleClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<ReactionViewer
room={room}
relations={relations}
requestClose={() => setOpen(false)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Smile} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
View Reactions
</Text>
</MenuItem>
</>
);
});
export const MessageReadReceiptItem = as<
'button',
{
room: Room;
eventId: string;
onClose?: () => void;
}
>(({ room, eventId, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(360);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Read Receipts
</Text>
</MenuItem>
</>
);
});
export const MessageSourceCodeItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const getContent = (evt: MatrixEvent) =>
evt.isEncrypted()
? {
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
[`<== ORIGINAL_EVENT ==>`]: evt.event,
}
: evt.event;
const getText = (): string => {
const evtId = mEvent.getId()!;
const evtTimeline = room.getTimelineForEvent(evtId);
const edits =
evtTimeline &&
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
const content: Record<string, unknown> = {
'<== MAIN_EVENT ==>': getContent(mEvent),
};
edits.forEach((editEvt, index) => {
content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
});
return JSON.stringify(content, null, 2);
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<TextViewer
name="Source Code"
langName="json"
text={getText()}
requestClose={handleClose}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BlockCode} />}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
View Source
</Text>
</MenuItem>
</>
);
});
export const MessageCopyLinkItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room)));
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
);
});
// [Gitea #130] Same as Copy Link but a direct link into THIS deployment, for
// recipients who use Lotus (matrix.to cannot be pointed here).
export const MessageCopyLotusLinkItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { hashRouter } = useClientConfig();
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(
getLotusRoomPermalink(
getOriginBaseUrl(hashRouter),
room.roomId,
eventId,
getViaServers(room),
),
);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
);
});
// Copies the message's plain-text body (reply fallback stripped) to the
// clipboard. Renders nothing for events without a usable text body (e.g. media
// without a caption), so the caller can list it unconditionally.
export const MessageCopyTextItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
// Text-only: for media the `body` is the filename (or caption). We don't want
// "Copy Text" to copy a filename, so gate to textual message types.
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
if (!isTextual || !body) return null;
const handleCopy = () => {
copyToClipboard(body);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Text} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Text
</Text>
</MenuItem>
);
});
export const MessageTranslateItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
const eventId = mEvent.getId() ?? '';
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
// On-device translation is Chromium-desktop only; hide the action entirely
// where the engine can't run, and for non-textual/empty messages.
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
const handleToggle = () => {
setActive((a) => !a);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Globe} />}
radii="300"
onClick={handleToggle}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{active ? 'Show Original' : 'Translate'}
</Text>
</MenuItem>
);
});
export const MessagePinItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const pinnedEvents = useRoomPinnedEvents(room);
const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
const handlePin = () => {
const eventId = mEvent.getId();
const pinContent: RoomPinnedEventsEventContent = {
pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
};
if (!isPinned && eventId) {
pinContent.pinned.push(eventId);
}
sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pin} />}
radii="300"
onClick={handlePin}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{isPinned ? 'Unpin Message' : 'Pin Message'}
</Text>
</MenuItem>
);
});
export const MessageDeleteItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const [deleteState, deleteMessage] = useAsyncCallback(
useCallback(
(eventId: string, reason?: string) =>
mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
[mx, room],
),
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const eventId = mEvent.getId();
if (
!eventId ||
deleteState.status === AsyncStatus.Loading ||
deleteState.status === AsyncStatus.Success
)
return;
const target = evt.target as HTMLFormElement | undefined;
const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
const reason = reasonInput && reasonInput.value.trim();
deleteMessage(eventId, reason);
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Delete Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">
This action is irreversible! Are you sure that you want to delete this message?
</Text>
<Box direction="Column" gap="100">
<Text size="L400">
Reason{' '}
<Text as="span" size="T200">
(optional)
</Text>
</Text>
<Input name="reasonInput" variant="Background" />
{deleteState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to delete message! Please try again.
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
before={
deleteState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={deleteState.status === AsyncStatus.Loading}
>
<Text size="B400">
{deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Delete
</Text>
</Button>
</>
);
});
export const MessageReportItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(480);
const [reportState, reportMessage] = useAsyncCallback(
useCallback(
(eventId: string, score: number, reason: string) =>
mx.reportEvent(room.roomId, eventId, score, reason),
[mx, room],
),
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const eventId = mEvent.getId();
if (
!eventId ||
reportState.status === AsyncStatus.Loading ||
reportState.status === AsyncStatus.Success
)
return;
const target = evt.target as HTMLFormElement | undefined;
const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
const reason = reasonInput && reasonInput.value.trim();
if (reasonInput) reasonInput.value = '';
reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Report Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">
Report this message to server, which may then notify the appropriate people to
take action.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Reason</Text>
<Input name="reasonInput" variant="Background" required />
{reportState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to report message! Please try again.
</Text>
)}
{reportState.status === AsyncStatus.Success && (
<Text style={{ color: color.Success.Main }} size="T300">
Message has been reported to server.
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
before={
reportState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
reportState.status === AsyncStatus.Loading ||
reportState.status === AsyncStatus.Success
}
>
<Text size="B400">
{reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Report
</Text>
</Button>
</>
);
});
export type MessageProps = {
room: Room;
mEvent: MatrixEvent;
collapse: boolean;
highlight: boolean;
edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: (
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
startThread?: boolean,
) => void;
onEditId?: (eventId?: string) => void;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode;
reactions?: ReactNode;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
memberPowerTag?: MemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
lotusTerminal?: boolean;
};
export const Message = React.memo(
as<'div', MessageProps>(
(
{
className,
room,
mEvent,
collapse,
highlight,
edit,
canDelete,
canSendReaction,
canPinEvent,
imagePackRooms,
relations,
messageLayout,
messageSpacing,
onUserClick,
onUsernameClick,
onReplyClick,
onReactionToggle,
onEditId,
reply,
reactions,
hideReadReceipts,
showDeveloperTools,
memberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
lotusTerminal: lotusTerminalProp,
children,
...props
},
ref,
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const readPositions = useReadPositions();
const readReceiptUsers = hideReadReceipts
? []
: (readPositions.get(mEvent.getId() ?? '') ?? []);
const isMine = mEvent.getSender() === mx.getUserId();
const lotusTerminal = lotusTerminalProp;
// Track whether this message should play the appear animation (own messages only)
const isNewRef = useRef(true);
const [playAppear, setPlayAppear] = useState(isMine && isNewRef.current);
// Mention pulse: play once for new incoming @mention messages from others
const myUserId = mx.getUserId() ?? '';
const mentionContent = mEvent.getContent<{
'm.mentions'?: { user_ids?: string[]; room?: boolean };
}>();
const isMentioned =
!isMine &&
(mentionContent['m.mentions']?.user_ids?.includes(myUserId) === true ||
mentionContent['m.mentions']?.room === true);
const [playMentionPulse, setPlayMentionPulse] = useState(isMentioned && isNewRef.current);
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const [forwardOpen, setForwardOpen] = useState(false);
const [remindOpen, setRemindOpen] = useState(false);
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
const senderDisplayName = getMemberName(room, senderId);
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const tagIconSrc = memberPowerTag?.icon
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const headerJSX = !collapse && (
<Box
gap="300"
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
>
<Box alignItems="Center" gap="200">
<Username
as="button"
style={{ color: usernameColor }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text
as="span"
size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
truncate
>
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
</Text>
<Text as="span" size="T200" priority="300">
|
</Text>
</>
)}
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
</Box>
);
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<AvatarDecoration userId={senderId}>
<PresenceRingAvatar userId={senderId}>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
aria-label={`${senderDisplayName}, open profile`}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
</AvatarBase>
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
{readReceiptUsers.length > 0 && (
<ReadReceiptAvatars
room={room}
eventId={mEvent.getId() ?? ''}
userIds={readReceiptUsers}
/>
)}
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
)}
</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,
width: 0,
height: 0,
});
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
setSheetOpen(false);
};
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setEmojiBoardAnchor(target.getBoundingClientRect());
};
const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
const rect = menuAnchor;
closeMenu();
// open it with timeout because closeMenu
// FocusTrap will return focus from emojiBoard
setTimeout(() => {
setEmojiBoardAnchor(rect);
}, 100);
};
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, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
[MsgAppearClass]: playAppear,
[MentionHighlightPulse]: playMentionPulse,
})}
role="article"
aria-label={
collapse
? messageAriaLabel(senderDisplayName, mEvent.getTs(), {
hour24Clock,
dateFormatString,
})
: undefined
}
tabIndex={0}
space={messageSpacing}
collapse={collapse}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
onAnimationEnd={() => {
if (playAppear) {
isNewRef.current = false;
setPlayAppear(false);
}
if (playMentionPulse) {
isNewRef.current = false;
setPlayMentionPulse(false);
}
}}
{...props}
{...hoverProps}
{...focusWithinProps}
ref={ref}
>
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
<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(mEvent.getId()!, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(mEvent.getId()!, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => {
setEmojiBoardAnchor(undefined);
}}
/>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Add reaction"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode);
setEmojiBoardAnchor(undefined);
}}
/>
)}
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply in thread"
>
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
>
<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: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
{menuJSX}
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
)}
{messageLayout === MessageLayout.Compact && (
<CompactLayout
before={headerJSX}
onContextMenu={handleContextMenu}
{...longPressHandlers}
>
{msgContentJSX}
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout
before={avatarJSX}
header={headerJSX}
onContextMenu={handleContextMenu}
{...longPressHandlers}
>
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<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)} />
)}
{remindOpen && mEvent.getId() && (
<RemindMeDialog
roomId={room.roomId}
eventId={mEvent.getId()!}
previewText={(mEvent.getContent()?.body as string | undefined)?.slice(0, 120) ?? ''}
onClose={() => setRemindOpen(false)}
/>
)}
</MessageBase>
);
},
),
);
export type EventProps = {
room: Room;
mEvent: MatrixEvent;
highlight: boolean;
canDelete?: boolean;
messageSpacing: MessageSpacing;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
};
export const Event = React.memo(
as<'div', EventProps>(
(
{
className,
room,
mEvent,
highlight,
canDelete,
messageSpacing,
hideReadReceipts,
showDeveloperTools,
children,
...props
},
ref,
) => {
const [hover, setHover] = useState(false);
const mx = useMatrixClient();
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const stateEvent = typeof mEvent.getStateKey() === 'string';
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
setMenuAnchor({
x: evt.clientX,
y: evt.clientY,
width: 0,
height: 0,
});
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
};
return (
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={messageSpacing}
autoCollapse
highlight={highlight}
selected={!!menuAnchor}
{...props}
{...hoverProps}
{...focusWithinProps}
ref={ref}
>
{(hover || !!menuAnchor) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
<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: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu {...props} ref={ref}>
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!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}
/>
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
<>
<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>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
)}
<div onContextMenu={handleContextMenu}>{children}</div>
</MessageBase>
);
},
),
);