1554 lines
56 KiB
TypeScript
1554 lines
56 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
);
|