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 ( <> }>
{t('Room.delete_message')}
{t('Room.delete_confirm')} {t('Room.reason')}{' '} ({t('Room.optional')}) {deleteState.status === AsyncStatus.Error && ( {t('Room.delete_error')} )}
); }); 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 ( <> }>
{t('Room.report_message')}
{t('Room.report_desc')} {t('Room.report_reason')} {reportState.status === AsyncStatus.Error && ( {t('Room.report_error')} )} {reportState.status === AsyncStatus.Success && ( {t('Room.report_success')} )}
); }); 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) && (
{canSendReaction && ( { const evtId = mEvent.getId(); if (evtId) onReactionToggle(evtId, key, shortcode); }} /> )} {canSendReaction && ( { const evtId = mEvent.getId(); if (evtId) onReactionToggle(evtId, key); setEmojiBoardAnchor(undefined); }} onCustomEmojiSelect={(mxc, shortcode) => { const evtId = mEvent.getId(); if (evtId) onReactionToggle(evtId, mxc, shortcode); setEmojiBoardAnchor(undefined); }} requestClose={() => { setEmojiBoardAnchor(undefined); }} /> } > )} {!hideMainReplyAffordance && ( )} {!isThreadReply && !hideThreadReplyAffordance && ( [0]) => onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" size="300" radii="300" > )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" radii="300" > )} setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {canSendReaction && ( { const evtId = mEvent.getId(); if (evtId) onReactionToggle(evtId, key, shortcode); closeMenu(); }} /> )} {canSendReaction && ( } radii="300" onClick={handleAddReactions} > {t('Room.add_reaction')} )} {relations && ( )} {!hideMainReplyAffordance && ( } radii="300" data-event-id={mEvent.getId()} onClick={(evt: Parameters[0]) => { onReplyClick(evt); closeMenu(); }} > {t('Room.reply')} )} {!isThreadReply && !hideThreadReplyAffordance && ( } radii="300" data-event-id={mEvent.getId()} onClick={(evt: Parameters[0]) => { onReplyClick(evt, true); closeMenu(); }} > {t('Room.reply_in_thread')} )} {canEditEvent(mx, mEvent) && onEditId && ( } radii="300" data-event-id={mEvent.getId()} onClick={() => { onEditId(mEvent.getId()); closeMenu(); }} > {t('Room.edit_message')} )} {!hideReadReceipts && ( )} {showDeveloperTools && ( )} {canPinEvent && ( )} {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } >
)} {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} ) : ( } 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) && (
setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > {!hideReadReceipts && ( )} {showDeveloperTools && ( )} {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( <> {!mEvent.isRedacted() && canDelete && ( )} {mEvent.getSender() !== mx.getUserId() && ( )} )} } >
)}
{children}
); } );