vojo/src/app/features/room/message/Message.tsx

1554 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <span />;
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>
<Line size="300" />
</>
);
}
);
// 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) => (
<IconButton
key={emoji.unicode}
size="300"
variant="SurfaceVariant"
radii="300"
title={emoji.shortcode}
aria-label={emoji.shortcode}
onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
>
<Text size="T400">{emoji.unicode}</Text>
</IconButton>
))}
</>
);
}
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 (
<>
<Overlay
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
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>
{t('Room.view_reactions')}
</Text>
</MenuItem>
</>
);
});
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 (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<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>
{t('Room.read_receipts')}
</Text>
</MenuItem>
</>
);
});
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<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={t('Room.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>
{t('Room.view_source')}
</Text>
</MenuItem>
</>
);
});
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 (
<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>
{t('Room.copy_link')}
</Text>
</MenuItem>
);
});
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 (
<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 ? t('Room.unpin_message') : t('Room.pin_message')}
</Text>
</MenuItem>
);
});
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<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">{t('Room.delete_message')}</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">{t('Room.delete_confirm')}</Text>
<Box direction="Column" gap="100">
<Text size="L400">
{t('Room.reason')}{' '}
<Text as="span" size="T200">
({t('Room.optional')})
</Text>
</Text>
<Input name="reasonInput" variant="Background" />
{deleteState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{t('Room.delete_error')}
</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
? t('Room.deleting')
: t('Room.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>
{t('Room.delete')}
</Text>
</Button>
</>
);
});
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<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 || t('Room.no_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">{t('Room.report_message')}</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">{t('Room.report_desc')}</Text>
<Box direction="Column" gap="100">
<Text size="L400">{t('Room.report_reason')}</Text>
<Input name="reasonInput" variant="Background" required />
{reportState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{t('Room.report_error')}
</Text>
)}
{reportState.status === AsyncStatus.Success && (
<Text style={{ color: color.Success.Main }} size="T300">
{t('Room.report_success')}
</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
? t('Room.reporting')
: t('Room.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>
{t('Room.report')}
</Text>
</Button>
</>
);
});
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<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;
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<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
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 = (
<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
)}
</Box>
);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (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<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
setMenuAnchor(target.getBoundingClientRect());
};
const closeMenu = () => {
setMenuAnchor(undefined);
};
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;
// 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 (
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
// Stream rows always render with collapsed marginTop (=0) so the
// bubble-to-bubble gap is uniform regardless of same-sender grouping.
// Total gap = 4×S100 (StreamRoot + MessageBase vertical padding on
// both sides) = 16px. The rail-bridge in layout.css.ts is still S400
// each side — it overshoots slightly across the smaller gap, but the
// overlap lands inside DotColumn behind the dot halo and stays
// invisible. `collapse` still drives avatar/header visibility inside
// ChannelLayout — channel mode keeps the original behaviour.
collapse={layout === 'channel' ? collapse : true}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
{...props}
{...hoverProps}
{...focusWithinProps}
ref={ref}
>
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
{canSendReaction && (
<RailQuickReactions
onReaction={(key, shortcode) => {
const evtId = mEvent.getId();
if (evtId) onReactionToggle(evtId, key, shortcode);
}}
/>
)}
{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) => {
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);
}}
/>
}
>
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
{!hideMainReplyAffordance && (
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
)}
{!isThreadReply && !hideThreadReplyAffordance && (
<IconButton
onClick={(ev: Parameters<typeof onReplyClick>[0]) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
const evtId = mEvent.getId();
if (evtId) onReactionToggle(evtId, key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.add_reaction')}
</Text>
</MenuItem>
)}
{relations && (
<MessageAllReactionItem
room={room}
relations={relations}
onClose={closeMenu}
/>
)}
{!hideMainReplyAffordance && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
onReplyClick(evt);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.reply')}
</Text>
</MenuItem>
)}
{!isThreadReply && !hideThreadReplyAffordance && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.reply_in_thread')}
</Text>
</MenuItem>
)}
{canEditEvent(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
>
{t('Room.edit_message')}
</Text>
</MenuItem>
)}
{!hideReadReceipts && (
<MessageReadReceiptItem
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
)}
{showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</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>
</FocusTrap>
}
>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
)}
{layout === 'channel' ? (
<ChannelLayout
isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble}
avatar={
!collapse ? (
<ChannelMessageAvatar
room={room}
senderId={senderId}
senderDisplayName={senderDisplayName}
/>
) : undefined
}
header={
!collapse ? (
<>
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
{/* 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. */}
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
{/* Own messages show the user's own nick too (not a «me»
label) — matches the 1:1 Stream layout. */}
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</>
) : undefined
}
reactions={reactions}
threadSummary={threadSummary}
onContextMenu={handleContextMenu}
>
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</ChannelLayout>
) : (
<StreamLayout
time={<Time ts={mEvent.getTs()} compact />}
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.
<Username
as="button"
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
{/* Own messages show the user's own nick (display name), not
a «me» label — VS-Code-style per-author turn labels. */}
{senderDisplayName}
</Username>
)
}
onContextMenu={handleContextMenu}
>
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</StreamLayout>
)}
</MessageBase>
);
}
);
// 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<typeof MessageInner>,
next: ComponentProps<typeof MessageInner>
): 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<string, unknown>)['data-message-item'] ===
(next as Record<string, unknown>)['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<RectCords>();
const stateEvent = typeof mEvent.getStateKey() === 'string';
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (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<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={STREAM_MESSAGE_SPACING}
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}
/>
)}
<MessageCopyLinkItem 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-pressed={!!menuAnchor}
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
</PopOut>
</Box>
</Menu>
</div>
)}
<div onContextMenu={handleContextMenu}>{children}</div>
</MessageBase>
);
}
);