import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Line,
Menu,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
RectCords,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import React, {
ComponentProps,
FormEventHandler,
memo,
MouseEventHandler,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
CHANNEL_MESSAGE_SPACING,
ChannelLayout,
ChannelMessageAvatar,
MessageBase,
STREAM_MESSAGE_SPACING,
StreamLayout,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
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 { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import colorMXID from '../../../../util/colorMXID';
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, 4);
if (recentEmojis.length === 0) return ;
return (
<>
{recentEmojis.map((emoji) => (
onReaction(emoji.unicode, emoji.shortcode)}
>
{emoji.unicode}
))}
>
);
}
);
// Default reactions shown in the hover rail before a user has built up any
// recent-emoji history, so the "быстрый рельс" always offers one-tap reactions.
const RAIL_DEFAULT_REACTIONS: { unicode: string; shortcode: string }[] = [
{ unicode: '👍', shortcode: 'thumbsup' },
{ unicode: '❤️', shortcode: 'heart' },
{ unicode: '😂', shortcode: 'joy' },
];
type RailQuickReactionsProps = {
onReaction: (key: string, shortcode?: string) => void;
};
// Inline quick-reactions surfaced directly in the hover action rail — react in
// one tap without opening the emoji board. Mounts ONLY inside the
// conditionally-rendered rail (one row at a time), so there is no per-row cost.
function RailQuickReactions({ onReaction }: RailQuickReactionsProps) {
const mx = useMatrixClient();
const recent = useRecentEmoji(mx, 3);
const items =
recent.length > 0
? recent.slice(0, 3).map((e) => ({ unicode: e.unicode, shortcode: e.shortcode }))
: RAIL_DEFAULT_REACTIONS;
return (
<>
{items.map((emoji) => (
onReaction(emoji.unicode, emoji.shortcode)}
>
{emoji.unicode}
))}
>
);
}
export const MessageAllReactionItem = as<
'button',
{
room: Room;
relations: Relations;
onClose?: () => void;
}
>(({ room, relations, onClose, ...props }, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
) => {
evt.stopPropagation();
}}
open={open}
backdrop={}
>
handleClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
setOpen(false)}
/>
}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
{t('Room.view_reactions')}
>
);
});
export const MessageReadReceiptItem = as<
'button',
{
room: Room;
eventId: string;
onClose?: () => void;
}
>(({ room, eventId, onClose, ...props }, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
{t('Room.read_receipts')}
>
);
});
export const MessageSourceCodeItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { t } = useTranslation();
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();
if (!evtId) return '';
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 = {
'<== 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 (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
{...props}
ref={ref}
aria-pressed={open}
>
{t('Room.view_source')}
>
);
});
export const MessageCopyLinkItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { t } = useTranslation();
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room)));
onClose?.();
};
return (
}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
{t('Room.copy_link')}
);
});
export const MessagePinItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { t } = useTranslation();
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);
}
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as keyof StateEvents, pinContent);
onClose?.();
};
return (
}
radii="300"
onClick={handlePin}
{...props}
ref={ref}
>
{isPinned ? t('Room.unpin_message') : t('Room.pin_message')}
);
});
export const MessageDeleteItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { t } = useTranslation();
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 = (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 (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
{t('Room.delete')}
>
);
});
export const MessageReportItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const [reportState, reportMessage] = useAsyncCallback(
useCallback(
(eventId: string, score: number, reason: string) =>
mx.reportEvent(room.roomId, eventId, score, reason),
[mx, room]
)
);
const handleSubmit: FormEventHandler = (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 || t('Room.no_reason'));
};
const handleClose = () => {
setOpen(false);
onClose?.();
};
return (
<>
}>
}
radii="300"
onClick={() => setOpen(true)}
aria-pressed={open}
{...props}
ref={ref}
>
{t('Room.report')}
>
);
});
export type MessageProps = {
room: Room;
mEvent: MatrixEvent;
collapse: boolean;
// Stream layout only: suppress the rail segment on this row — set for a
// trailing continuation that sits after the last dot, so the rail stops at
// the last dot instead of bleeding down the dot-less tail of a run. Ignored
// by the channel layout.
railHidden?: boolean;
highlight: boolean;
edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
onUserClick: MouseEventHandler;
onUsernameClick: MouseEventHandler;
onReplyClick: (
ev: Parameters>[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;
legacyUsernameColor?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
// M2: hide the «Reply in Thread» menu/quick action. Set by
// RoomTimeline outside channels-mode (where threads aren't surfaced
// anywhere) and inside bridged channels (where the bridge has no
// thread semantic). When true, the ThreadPlus button + the
// `reply_in_thread` menu item are skipped; the regular «Reply»
// affordance stays untouched.
hideThreadReplyAffordance?: boolean;
// M2: hide the regular «Reply» (m.in_reply_to) menu/quick action.
// Set by RoomTimeline when the thread drawer is open — the channel
// composer is unmounted in that state, so the reply chip would
// write to an unread atom and the focus call would no-op silently.
// ThreadPlus button stays usable for navigating to a different
// thread root.
hideMainReplyAffordance?: boolean;
// Snapshot of `mEvent.getContent().msgtype` from the caller. Threaded as
// a prop (not derived locally) so encrypted-then-decrypted events flip
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
// local useState would race against the commit↔effect gap.
msgType?: string;
// M3a: thread summary pill (count + last reply preview) rendered
// between the bubble and reactions. Caller (`RoomTimeline`) decides
// whether to mount the card based on `channelsMode && !isBridged` —
// outside channels-mode this stays `undefined` and the slot collapses.
threadSummary?: React.ReactNode;
// Choose timeline visual. `'stream'` = rail + dot + bubble — used for
// 1:1 DMs and 1:1 bot control rooms. `'channel'` = avatar + bubble with
// an in-bubble username/time header — used for every non-1:1 room
// (group DMs, group rooms under /space/, channels). Default `'stream'`
// so card-preview callers (pin menu, message search, inbox) get the
// legacy DM look.
layout?: 'stream' | 'channel';
// Forwarded to `ChannelLayout.headerInBubble`. Every current timeline
// caller passes `true` alongside `layout='channel'`; kept as a prop so
// un-bubbled `ChannelLayout` consumers stay possible.
channelHeaderInBubble?: boolean;
// Parent-computed render snapshot (see getMessageRenderSig). Drives the
// React.memo comparator so in-place MatrixEvent mutations (edit/redact/
// decrypt/echo) and rename/urlPreview changes repaint the row.
renderSig?: string;
};
const MessageInner = as<'div', MessageProps>(
(
{
className,
room,
mEvent,
collapse,
railHidden,
highlight,
edit,
canDelete,
canSendReaction,
canPinEvent,
imagePackRooms,
relations,
onUserClick,
onUsernameClick,
onReplyClick,
onReactionToggle,
onEditId,
reply,
reactions,
hideReadReceipts,
showDeveloperTools,
memberPowerTag,
accessibleTagColors,
legacyUsernameColor,
streamRailStart,
streamRailEnd,
hideThreadReplyAffordance,
hideMainReplyAffordance,
msgType,
threadSummary,
layout = 'stream',
channelHeaderInBubble,
children,
...props
},
ref
) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState();
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
// Only the Stream layout renders the rail dot — skip the push-action eval
// and Receipt subscription on channel-layout rows where it is discarded.
const dot = useDotColor(room, mEvent, layout !== 'channel', hideReadReceipts);
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
// msgType comes from the parent — RoomTimeline reads
// `mEvent.getContent().msgtype` synchronously and re-evaluates inside
// EncryptedContent's render-prop, which re-runs on Decrypted. Avoiding
// a local useState here sidesteps the commit↔effect race where
// decryption fires between render and listener attach.
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
const mediaMode = isMediaMessage && !edit;
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
logMedia('Message', {
eventId: mEvent.getId(),
msgType,
isMediaMessage,
edit,
mediaMode,
isMobile,
screenSize,
});
}
const streamMediaCtx = useMemo(
() =>
// Image / video chrome (the StreamMediaImage/Video shell) — used in
// BOTH the 1:1 Stream layout and the Discord-style channel layout, so
// group-chat media renders identically to 1:1 (rounded shell, capped
// size) instead of the legacy boxed attachment. Only `own` is needed
// (the bubble's notch corner); the nick is rendered above by the header.
mediaMode ? { own: isOwnMessage } : null,
[mediaMode, isOwnMessage]
);
const msgContentJSX = (
{reply}
{edit && onEditId ? (
onEditId()}
/>
) : (
children
)}
);
const handleContextMenu: MouseEventHandler = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as Element).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 = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
};
const handleOpenEmojiBoard: MouseEventHandler = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setEmojiBoardAnchor(target.getBoundingClientRect());
};
const handleAddReactions: MouseEventHandler = () => {
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;
// After a Thread is created on a root event, SDK sets
// `mEvent.threadRootId === mEvent.getId()` and `isThreadRoot ===
// true`. We want the «Reply in Thread» button to keep working on
// the root (re-open drawer) — only hide when the message is itself
// a thread reply (no thread can be started on a reply, and in
// channels-mode replies aren't visible in main timeline anyway).
const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot;
return (
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
)}
{layout === 'channel' ? (
) : undefined
}
header={
!collapse ? (
<>
{/* In-bubble (thread) uses Stream's compact T200 size
so the bubble header matches the DM-chat rhythm.
Channels main timeline keeps T400 for the prominent
avatar-and-name row above an unbordered body. */}
{/* Own messages show the user's own nick too (not a «me»
label) — matches the 1:1 Stream layout. */}
{senderDisplayName}
>
) : undefined
}
reactions={reactions}
threadSummary={threadSummary}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
) : (
}
dotColor={dot.color}
dotOpacity={dot.opacity}
dotProminent={dot.prominent}
isOwn={isOwnMessage}
compact={isMobile}
// A same-sender continuation row (any minute): hide the dot / time
// / nick and stack the body tight under the previous one. Only the
// first message of a run keeps the full header. See RoomTimeline
// `collapsed`.
collapsed={collapse}
railStart={streamRailStart}
railEnd={streamRailEnd}
railHidden={railHidden}
mediaMode={mediaMode}
reactions={reactions}
threadSummary={threadSummary}
header={
// Author nick prints once at the head of a same-sender run; every
// continuation row (`collapse`) drops it. Media heads show the
// nick here too (above the media) — there's no overlay on the
// image any more.
collapse ? undefined : (
// No inline colour / size — the `StreamName` wrapper supplies
// the bold, pure white/black, larger-than-body styling, and the
// button inherits it. css.Username already truncates.
{/* Own messages show the user's own nick (display name), not
a «me» label — VS-Code-style per-author turn labels. */}
{senderDisplayName}
)
}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
)}
);
}
);
// Parent-computed render snapshot for a timeline message row. This MUST be
// called during the PARENT's render and threaded in as the `renderSig` prop;
// it must NOT be recomputed from `mEvent` inside the memo comparator.
//
// matrix-js-sdk mutates the `MatrixEvent` IN PLACE on edit / redaction /
// decryption / local-echo (makeReplaced / makeRedacted / setClearData /
// handleRemoteEcho), and the timeline reuses one MatrixEvent instance per id,
// so `prevProps.mEvent === nextProps.mEvent`. A signature read off that live
// object inside the comparator would return the SAME (already-mutated) value
// for both prev and next and could never detect the change -- the classic
// stale-row trap element-web documents on `EventTile` (`isRedacted` is a prop
// there for exactly this reason). Capturing the snapshot here freezes it into
// the prop value, so React's retained prevProps holds the OLD snapshot and the
// comparator can diff old-vs-new. It also folds in the sender display name and
// the urlPreview setting, so a live rename / preview-toggle repaints the row.
export function getMessageRenderSig(
room: Room,
mEvent: MatrixEvent,
editedEvent: MatrixEvent | undefined,
urlPreview: boolean | undefined
): string {
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const content = mEvent.getContent();
return [
mEvent.getId() ?? '',
mEvent.getType(),
typeof content.msgtype === 'string' ? content.msgtype : '',
mEvent.isRedacted() ? '1' : '0',
editedEvent?.getId() ?? '',
mEvent.status ?? '',
senderDisplayName,
urlPreview ? '1' : '0',
].join('\u0001');
}
// Custom equality for the timeline row. RoomTimeline re-renders on every live
// event / receipt / scroll-state change and rebuilds all ~80 visible rows; this
// lets React skip rows whose visible output is unchanged. SAFE because the
// dynamic children self-subscribe to their own data (Reactions→useRelations,
// Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the delivery dot
// self-subscribes (useDotColor→RoomEvent.Receipt) — so we only need to compare
// the row's own scalar/identity props, the PRESENCE of the dynamic node slots,
// and the parent-computed renderSig snapshot. The comparator errs toward re-rendering:
// any prop whose identity is unstable simply defeats the memo (no perf win) but
// never causes staleness. Returns true to SKIP the re-render.
//
// The mutable-event vectors (edit / redaction / decryption / local-echo)
// AND the children-only inputs (sender display name, urlPreview) are all
// folded into the parent-computed `renderSig` prop (see getMessageRenderSig),
// so they repaint correctly. `mediaAutoLoad` is mount-time (autoPlay /
// initial fetch), so it needs no signature term.
function areMessagePropsEqual(
prev: ComponentProps,
next: ComponentProps
): boolean {
return (
prev.room === next.room &&
prev.mEvent === next.mEvent &&
prev.relations === next.relations &&
prev.collapse === next.collapse &&
prev.railHidden === next.railHidden &&
prev.highlight === next.highlight &&
prev.edit === next.edit &&
prev.canDelete === next.canDelete &&
prev.canSendReaction === next.canSendReaction &&
prev.canPinEvent === next.canPinEvent &&
prev.imagePackRooms === next.imagePackRooms &&
prev.hideReadReceipts === next.hideReadReceipts &&
prev.showDeveloperTools === next.showDeveloperTools &&
prev.memberPowerTag === next.memberPowerTag &&
prev.accessibleTagColors === next.accessibleTagColors &&
prev.legacyUsernameColor === next.legacyUsernameColor &&
prev.streamRailStart === next.streamRailStart &&
prev.streamRailEnd === next.streamRailEnd &&
prev.hideThreadReplyAffordance === next.hideThreadReplyAffordance &&
prev.hideMainReplyAffordance === next.hideMainReplyAffordance &&
prev.msgType === next.msgType &&
prev.layout === next.layout &&
prev.channelHeaderInBubble === next.channelHeaderInBubble &&
prev.className === next.className &&
prev.onUserClick === next.onUserClick &&
prev.onUsernameClick === next.onUsernameClick &&
prev.onReplyClick === next.onReplyClick &&
prev.onReactionToggle === next.onReactionToggle &&
prev.onEditId === next.onEditId &&
Boolean(prev.reply) === Boolean(next.reply) &&
Boolean(prev.reactions) === Boolean(next.reactions) &&
Boolean(prev.threadSummary) === Boolean(next.threadSummary) &&
// `data-message-item` is the row's ABSOLUTE virtual-paginator index, queried
// by scrollToItem(). It shifts for existing rows when older history
// paginates in at the top, so a skipped render must not leave it stale —
// live appends at the bottom don't move it, so the memo still fires there.
(prev as Record)['data-message-item'] ===
(next as Record)['data-message-item'] &&
prev.renderSig === next.renderSig
);
}
export const Message = memo(MessageInner, areMessagePropsEqual);
export type EventProps = {
room: Room;
mEvent: MatrixEvent;
highlight: boolean;
canDelete?: boolean;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
};
export const Event = as<'div', EventProps>(
(
{
className,
room,
mEvent,
highlight,
canDelete,
hideReadReceipts,
showDeveloperTools,
children,
...props
},
ref
) => {
const mx = useMatrixClient();
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState();
const stateEvent = typeof mEvent.getStateKey() === 'string';
const handleContextMenu: MouseEventHandler = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as Element).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 = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
};
return (
{(hover || !!menuAnchor) && (