Address findings from 2 review agents (core edit path was verified correct — media preservation incl. encrypted, threading, no-op guard): - Require a filename (utils/room.ts): canEditCaption now also checks the MSC2530 `filename` exists. Fixes media from clients that omit filename, where the editor prefilled the filename as a caption and clearing it wrote an empty body. Such media simply isn't caption-editable (matches renderCaption never showing a caption for it). - Carry m.mentions (MessageEditor): a caption edit now unions typed @-mentions with prior mentions like the text-edit path, so mentioning someone in a caption edit notifies them. - Double caption: revert to the editor replacing the content while editing (as text edits do) instead of rendering the media + its caption above an editor prefilled with the same caption — removes the confusing duplicate. - Removed-caption "(edited)" marker (RenderMessageContent): when a media message is edited but has no caption (e.g. the caption was removed), render the standalone "(edited)" affordance so Edit History stays reachable (previously it lived only inside the caption and vanished). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1565 lines
55 KiB
TypeScript
1565 lines
55 KiB
TypeScript
/* eslint-disable react-hooks/rules-of-hooks */
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
IconSrc,
|
|
Icons,
|
|
Input,
|
|
Line,
|
|
Menu,
|
|
MenuItem,
|
|
Modal,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
PopOut,
|
|
RectCords,
|
|
Spinner,
|
|
Text,
|
|
as,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import React, {
|
|
FormEventHandler,
|
|
MouseEventHandler,
|
|
ReactNode,
|
|
useCallback,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { useHover, useFocusWithin } from 'react-aria';
|
|
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
|
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
|
import classNames from 'classnames';
|
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
|
import {
|
|
AvatarBase,
|
|
BubbleLayout,
|
|
CompactLayout,
|
|
MessageBase,
|
|
ModernLayout,
|
|
Time,
|
|
Username,
|
|
UsernameBold,
|
|
} from '../../../components/message';
|
|
import {
|
|
canEditCaption,
|
|
canEditEventOrCaption,
|
|
getEventEdits,
|
|
getMemberAvatarMxc,
|
|
getMemberName,
|
|
sendStateEvent,
|
|
} from '../../../utils/room';
|
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
|
import { messageAriaLabel } from '../../../utils/a11y';
|
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
|
import * as css from './styles.css';
|
|
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
|
import { MentionHighlightPulse } from '../../../components/message/layout/layout.css';
|
|
import { EventReaders } from '../../../components/event-readers';
|
|
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
|
|
import { useReadPositions } from '../ReadPositionsContext';
|
|
import { TextViewer } from '../../../components/text-viewer';
|
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
import { EmojiBoard } from '../../../components/emoji-board';
|
|
import { ReactionViewer } from '../reaction-viewer';
|
|
import { MessageEditor } from './MessageEditor';
|
|
import { UserAvatar } from '../../../components/user-avatar';
|
|
import { copyToClipboard } from '../../../utils/dom';
|
|
import { stopPropagation } from '../../../utils/keyboard';
|
|
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
|
import { getViaServers } from '../../../plugins/via-servers';
|
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
|
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
|
import { PowerIcon } from '../../../components/power';
|
|
import colorMXID from '../../../../util/colorMXID';
|
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
|
import { RemindMeDialog } from './RemindMeDialog';
|
|
import { useBookmarks } from '../../../hooks/useBookmarks';
|
|
import { PresenceRingAvatar } from '../../../components/presence';
|
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
|
|
|
// Delivery status indicator for own messages
|
|
function DeliveryStatus({
|
|
status,
|
|
lotusTerminal,
|
|
}: {
|
|
status: string | null;
|
|
lotusTerminal: boolean;
|
|
}) {
|
|
if (status === null) return null; // confirmed by server — read receipts take over
|
|
let iconSrc: IconSrc;
|
|
let label: string;
|
|
let colorStyle: string;
|
|
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
|
|
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
|
iconSrc = Icons.Cross;
|
|
label = 'Failed to send';
|
|
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
|
|
} else if (status === EventStatus.QUEUED || isSending) {
|
|
iconSrc = Icons.Send;
|
|
label = isSending ? 'Sending...' : 'Queued';
|
|
colorStyle = lotusTerminal
|
|
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
|
|
: color.Secondary.Main;
|
|
} else {
|
|
iconSrc = Icons.Check;
|
|
label = 'Sent';
|
|
colorStyle = lotusTerminal
|
|
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
|
|
: color.Secondary.Main;
|
|
}
|
|
return (
|
|
<Box
|
|
as="span"
|
|
aria-label={label}
|
|
title={label}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
marginTop: '2px',
|
|
lineHeight: 1,
|
|
color: colorStyle,
|
|
opacity: 0.85,
|
|
userSelect: 'none',
|
|
...(lotusTerminal && status === EventStatus.NOT_SENT
|
|
? { textShadow: 'var(--lt-glow-red)' }
|
|
: {}),
|
|
}}
|
|
>
|
|
<span className={isSending ? SendingSpinClass : undefined}>
|
|
<Icon size="100" src={iconSrc} />
|
|
</span>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
|
|
|
type MessageQuickReactionsProps = {
|
|
onReaction: ReactionHandler;
|
|
};
|
|
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
|
({ onReaction, ...props }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const recentEmojis = useRecentEmoji(mx, 3);
|
|
|
|
if (recentEmojis.length === 0) return null;
|
|
return (
|
|
<>
|
|
<Box
|
|
style={{ padding: config.space.S200 }}
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
gap="200"
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
{recentEmojis.map((emoji) => (
|
|
<IconButton
|
|
key={emoji.unicode}
|
|
className={css.MessageQuickReaction}
|
|
size="300"
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
title={emoji.shortcode}
|
|
aria-label={emoji.shortcode}
|
|
onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
|
|
>
|
|
<Text size="T500">{emoji.unicode}</Text>
|
|
</IconButton>
|
|
))}
|
|
</Box>
|
|
</>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const MessageAllReactionItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
relations: Relations;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, relations, onClose, ...props }, ref) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Overlay
|
|
onContextMenu={(evt: any) => {
|
|
evt.stopPropagation();
|
|
}}
|
|
open={open}
|
|
backdrop={<OverlayBackdrop />}
|
|
>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
returnFocusOnDeactivate: false,
|
|
onDeactivate: () => handleClose(),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Modal variant="Surface" size="300">
|
|
<ReactionViewer
|
|
room={room}
|
|
relations={relations}
|
|
requestClose={() => setOpen(false)}
|
|
/>
|
|
</Modal>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Smile} />}
|
|
radii="300"
|
|
onClick={() => setOpen(true)}
|
|
{...props}
|
|
ref={ref}
|
|
aria-pressed={open}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
View Reactions
|
|
</Text>
|
|
</MenuItem>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export const MessageReadReceiptItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
eventId: string;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, eventId, onClose, ...props }, ref) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const 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>
|
|
Read Receipts
|
|
</Text>
|
|
</MenuItem>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export const MessageSourceCodeItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const getContent = (evt: MatrixEvent) =>
|
|
evt.isEncrypted()
|
|
? {
|
|
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
|
|
[`<== ORIGINAL_EVENT ==>`]: evt.event,
|
|
}
|
|
: evt.event;
|
|
|
|
const getText = (): string => {
|
|
const evtId = mEvent.getId()!;
|
|
const evtTimeline = room.getTimelineForEvent(evtId);
|
|
const edits =
|
|
evtTimeline &&
|
|
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
|
|
|
|
if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
|
|
|
|
const content: Record<string, unknown> = {
|
|
'<== MAIN_EVENT ==>': getContent(mEvent),
|
|
};
|
|
|
|
edits.forEach((editEvt, index) => {
|
|
content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
|
|
});
|
|
|
|
return JSON.stringify(content, null, 2);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: handleClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Modal variant="Surface" size="500">
|
|
<TextViewer
|
|
name="Source Code"
|
|
langName="json"
|
|
text={getText()}
|
|
requestClose={handleClose}
|
|
/>
|
|
</Modal>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.BlockCode} />}
|
|
radii="300"
|
|
onClick={() => setOpen(true)}
|
|
{...props}
|
|
ref={ref}
|
|
aria-pressed={open}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
View Source
|
|
</Text>
|
|
</MenuItem>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export const MessageCopyLinkItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const handleCopy = () => {
|
|
const eventId = mEvent.getId();
|
|
if (!eventId) return;
|
|
copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room)));
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Link} />}
|
|
radii="300"
|
|
onClick={handleCopy}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
Copy Link
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
});
|
|
|
|
export const MessagePinItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const pinnedEvents = useRoomPinnedEvents(room);
|
|
const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
|
|
const handlePin = () => {
|
|
const eventId = mEvent.getId();
|
|
const pinContent: RoomPinnedEventsEventContent = {
|
|
pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
|
|
};
|
|
if (!isPinned && eventId) {
|
|
pinContent.pinned.push(eventId);
|
|
}
|
|
sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, pinContent);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Pin} />}
|
|
radii="300"
|
|
onClick={handlePin}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
{isPinned ? 'Unpin Message' : 'Pin Message'}
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
});
|
|
|
|
export const MessageDeleteItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [deleteState, deleteMessage] = useAsyncCallback(
|
|
useCallback(
|
|
(eventId: string, reason?: string) =>
|
|
mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
|
|
[mx, room],
|
|
),
|
|
);
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
evt.preventDefault();
|
|
const eventId = mEvent.getId();
|
|
if (
|
|
!eventId ||
|
|
deleteState.status === AsyncStatus.Loading ||
|
|
deleteState.status === AsyncStatus.Success
|
|
)
|
|
return;
|
|
const target = evt.target as HTMLFormElement | undefined;
|
|
const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
|
|
const reason = reasonInput && reasonInput.value.trim();
|
|
deleteMessage(eventId, reason);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: handleClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog variant="Surface">
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text size="H4">Delete Message</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box
|
|
as="form"
|
|
onSubmit={handleSubmit}
|
|
style={{ padding: config.space.S400 }}
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<Text priority="400">
|
|
This action is irreversible! Are you sure that you want to delete this message?
|
|
</Text>
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">
|
|
Reason{' '}
|
|
<Text as="span" size="T200">
|
|
(optional)
|
|
</Text>
|
|
</Text>
|
|
<Input name="reasonInput" variant="Background" />
|
|
{deleteState.status === AsyncStatus.Error && (
|
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
|
Failed to delete message! Please try again.
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Button
|
|
type="submit"
|
|
variant="Critical"
|
|
before={
|
|
deleteState.status === AsyncStatus.Loading ? (
|
|
<Spinner fill="Solid" variant="Critical" size="200" />
|
|
) : undefined
|
|
}
|
|
aria-disabled={deleteState.status === AsyncStatus.Loading}
|
|
>
|
|
<Text size="B400">
|
|
{deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<Button
|
|
variant="Critical"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Delete} />}
|
|
radii="300"
|
|
onClick={() => setOpen(true)}
|
|
aria-pressed={open}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
Delete
|
|
</Text>
|
|
</Button>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export const MessageReportItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [reportState, reportMessage] = useAsyncCallback(
|
|
useCallback(
|
|
(eventId: string, score: number, reason: string) =>
|
|
mx.reportEvent(room.roomId, eventId, score, reason),
|
|
[mx, room],
|
|
),
|
|
);
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
evt.preventDefault();
|
|
const eventId = mEvent.getId();
|
|
if (
|
|
!eventId ||
|
|
reportState.status === AsyncStatus.Loading ||
|
|
reportState.status === AsyncStatus.Success
|
|
)
|
|
return;
|
|
const target = evt.target as HTMLFormElement | undefined;
|
|
const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
|
|
const reason = reasonInput && reasonInput.value.trim();
|
|
if (reasonInput) reasonInput.value = '';
|
|
reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: handleClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog variant="Surface">
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text size="H4">Report Message</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box
|
|
as="form"
|
|
onSubmit={handleSubmit}
|
|
style={{ padding: config.space.S400 }}
|
|
direction="Column"
|
|
gap="400"
|
|
>
|
|
<Text priority="400">
|
|
Report this message to server, which may then notify the appropriate people to
|
|
take action.
|
|
</Text>
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Reason</Text>
|
|
<Input name="reasonInput" variant="Background" required />
|
|
{reportState.status === AsyncStatus.Error && (
|
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
|
Failed to report message! Please try again.
|
|
</Text>
|
|
)}
|
|
{reportState.status === AsyncStatus.Success && (
|
|
<Text style={{ color: color.Success.Main }} size="T300">
|
|
Message has been reported to server.
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Button
|
|
type="submit"
|
|
variant="Critical"
|
|
before={
|
|
reportState.status === AsyncStatus.Loading ? (
|
|
<Spinner fill="Solid" variant="Critical" size="200" />
|
|
) : undefined
|
|
}
|
|
aria-disabled={
|
|
reportState.status === AsyncStatus.Loading ||
|
|
reportState.status === AsyncStatus.Success
|
|
}
|
|
>
|
|
<Text size="B400">
|
|
{reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<Button
|
|
variant="Critical"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Warning} />}
|
|
radii="300"
|
|
onClick={() => setOpen(true)}
|
|
aria-pressed={open}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
Report
|
|
</Text>
|
|
</Button>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export type MessageProps = {
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
collapse: boolean;
|
|
highlight: boolean;
|
|
edit?: boolean;
|
|
canDelete?: boolean;
|
|
canSendReaction?: boolean;
|
|
canPinEvent?: boolean;
|
|
imagePackRooms?: Room[];
|
|
relations?: Relations;
|
|
messageLayout: MessageLayout;
|
|
messageSpacing: MessageSpacing;
|
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
|
onReplyClick: (
|
|
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
|
|
startThread?: boolean,
|
|
) => void;
|
|
onEditId?: (eventId?: string) => void;
|
|
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
|
reply?: ReactNode;
|
|
reactions?: ReactNode;
|
|
hideReadReceipts?: boolean;
|
|
showDeveloperTools?: boolean;
|
|
memberPowerTag?: MemberPowerTag;
|
|
accessibleTagColors?: Map<string, string>;
|
|
legacyUsernameColor?: boolean;
|
|
hour24Clock: boolean;
|
|
dateFormatString: string;
|
|
lotusTerminal?: boolean;
|
|
};
|
|
export const Message = React.memo(
|
|
as<'div', MessageProps>(
|
|
(
|
|
{
|
|
className,
|
|
room,
|
|
mEvent,
|
|
collapse,
|
|
highlight,
|
|
edit,
|
|
canDelete,
|
|
canSendReaction,
|
|
canPinEvent,
|
|
imagePackRooms,
|
|
relations,
|
|
messageLayout,
|
|
messageSpacing,
|
|
onUserClick,
|
|
onUsernameClick,
|
|
onReplyClick,
|
|
onReactionToggle,
|
|
onEditId,
|
|
reply,
|
|
reactions,
|
|
hideReadReceipts,
|
|
showDeveloperTools,
|
|
memberPowerTag,
|
|
accessibleTagColors,
|
|
legacyUsernameColor,
|
|
hour24Clock,
|
|
dateFormatString,
|
|
lotusTerminal: lotusTerminalProp,
|
|
children,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const readPositions = useReadPositions();
|
|
const readReceiptUsers = hideReadReceipts
|
|
? []
|
|
: (readPositions.get(mEvent.getId() ?? '') ?? []);
|
|
const isMine = mEvent.getSender() === mx.getUserId();
|
|
const lotusTerminal = lotusTerminalProp;
|
|
// Track whether this message should play the appear animation (own messages only)
|
|
const isNewRef = useRef(true);
|
|
const [playAppear, setPlayAppear] = useState(isMine && isNewRef.current);
|
|
// Mention pulse: play once for new incoming @mention messages from others
|
|
const myUserId = mx.getUserId() ?? '';
|
|
const mentionContent = mEvent.getContent<{
|
|
'm.mentions'?: { user_ids?: string[]; room?: boolean };
|
|
}>();
|
|
const isMentioned =
|
|
!isMine &&
|
|
(mentionContent['m.mentions']?.user_ids?.includes(myUserId) === true ||
|
|
mentionContent['m.mentions']?.room === true);
|
|
const [playMentionPulse, setPlayMentionPulse] = useState(isMentioned && isNewRef.current);
|
|
const [hover, setHover] = useState(false);
|
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
|
const [forwardOpen, setForwardOpen] = useState(false);
|
|
const [remindOpen, setRemindOpen] = useState(false);
|
|
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
|
|
|
|
const senderDisplayName = getMemberName(room, senderId);
|
|
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
|
|
|
const tagColor = memberPowerTag?.color
|
|
? accessibleTagColors?.get(memberPowerTag.color)
|
|
: undefined;
|
|
const tagIconSrc = memberPowerTag?.icon
|
|
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
|
: undefined;
|
|
|
|
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
|
|
|
const headerJSX = !collapse && (
|
|
<Box
|
|
gap="300"
|
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
|
justifyContent="SpaceBetween"
|
|
alignItems="Baseline"
|
|
grow="Yes"
|
|
>
|
|
<Box alignItems="Center" gap="200">
|
|
<Username
|
|
as="button"
|
|
style={{ color: usernameColor }}
|
|
data-user-id={senderId}
|
|
onContextMenu={onUserClick}
|
|
onClick={onUsernameClick}
|
|
>
|
|
<Text
|
|
as="span"
|
|
size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
|
|
truncate
|
|
>
|
|
<UsernameBold>{senderDisplayName}</UsernameBold>
|
|
</Text>
|
|
</Username>
|
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
|
</Box>
|
|
<Box shrink="No" gap="100">
|
|
{messageLayout === MessageLayout.Modern && hover && (
|
|
<>
|
|
<Text as="span" size="T200" priority="300">
|
|
{senderId}
|
|
</Text>
|
|
<Text as="span" size="T200" priority="300">
|
|
|
|
|
</Text>
|
|
</>
|
|
)}
|
|
<Time
|
|
ts={mEvent.getTs()}
|
|
compact={messageLayout === MessageLayout.Compact}
|
|
hour24Clock={hour24Clock}
|
|
dateFormatString={dateFormatString}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
|
<AvatarBase
|
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
|
>
|
|
<AvatarDecoration userId={senderId}>
|
|
<PresenceRingAvatar userId={senderId}>
|
|
<Avatar
|
|
className={css.MessageAvatar}
|
|
as="button"
|
|
size="300"
|
|
data-user-id={senderId}
|
|
onClick={onUserClick}
|
|
>
|
|
<UserAvatar
|
|
userId={senderId}
|
|
src={
|
|
senderAvatarMxc
|
|
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
|
|
undefined)
|
|
: undefined
|
|
}
|
|
alt={senderDisplayName}
|
|
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
|
/>
|
|
</Avatar>
|
|
</PresenceRingAvatar>
|
|
</AvatarDecoration>
|
|
</AvatarBase>
|
|
);
|
|
|
|
const msgContentJSX = (
|
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
|
{reply}
|
|
{edit && onEditId ? (
|
|
<MessageEditor
|
|
style={{
|
|
maxWidth: '100%',
|
|
width: '100vw',
|
|
}}
|
|
roomId={room.roomId}
|
|
room={room}
|
|
mEvent={mEvent}
|
|
imagePackRooms={imagePackRooms}
|
|
onCancel={() => onEditId()}
|
|
/>
|
|
) : (
|
|
children
|
|
)}
|
|
{reactions}
|
|
{readReceiptUsers.length > 0 && (
|
|
<ReadReceiptAvatars
|
|
room={room}
|
|
eventId={mEvent.getId() ?? ''}
|
|
userIds={readReceiptUsers}
|
|
/>
|
|
)}
|
|
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
|
|
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
|
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
|
const tag = (evt.target as any).tagName;
|
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
|
evt.preventDefault();
|
|
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;
|
|
|
|
return (
|
|
<MessageBase
|
|
className={classNames(css.MessageBase, className, {
|
|
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
|
[MsgAppearClass]: playAppear,
|
|
[MentionHighlightPulse]: playMentionPulse,
|
|
})}
|
|
role="article"
|
|
aria-label={
|
|
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
|
}
|
|
tabIndex={0}
|
|
space={messageSpacing}
|
|
collapse={collapse}
|
|
highlight={highlight}
|
|
selected={!!menuAnchor || !!emojiBoardAnchor}
|
|
onAnimationEnd={() => {
|
|
if (playAppear) {
|
|
isNewRef.current = false;
|
|
setPlayAppear(false);
|
|
}
|
|
if (playMentionPulse) {
|
|
isNewRef.current = false;
|
|
setPlayMentionPulse(false);
|
|
}
|
|
}}
|
|
{...props}
|
|
{...hoverProps}
|
|
{...focusWithinProps}
|
|
ref={ref}
|
|
>
|
|
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
|
<div className={css.MessageOptionsBase}>
|
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
|
<Box gap="100">
|
|
{canSendReaction && (
|
|
<PopOut
|
|
position="Bottom"
|
|
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
|
|
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
|
|
anchor={emojiBoardAnchor}
|
|
content={
|
|
<EmojiBoard
|
|
imagePackRooms={imagePackRooms ?? []}
|
|
returnFocusOnDeactivate={false}
|
|
allowTextCustomEmoji
|
|
onEmojiSelect={(key) => {
|
|
onReactionToggle(mEvent.getId()!, key);
|
|
setEmojiBoardAnchor(undefined);
|
|
}}
|
|
onCustomEmojiSelect={(mxc, shortcode) => {
|
|
onReactionToggle(mEvent.getId()!, mxc, shortcode);
|
|
setEmojiBoardAnchor(undefined);
|
|
}}
|
|
requestClose={() => {
|
|
setEmojiBoardAnchor(undefined);
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<IconButton
|
|
onClick={handleOpenEmojiBoard}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Add reaction"
|
|
aria-pressed={!!emojiBoardAnchor}
|
|
>
|
|
<Icon src={Icons.SmilePlus} size="100" />
|
|
</IconButton>
|
|
</PopOut>
|
|
)}
|
|
{canSendReaction && (
|
|
<MessageQuickReactions
|
|
onReaction={(key, shortcode) => {
|
|
onReactionToggle(mEvent.getId()!, key, shortcode);
|
|
setEmojiBoardAnchor(undefined);
|
|
}}
|
|
/>
|
|
)}
|
|
<IconButton
|
|
onClick={onReplyClick}
|
|
data-event-id={mEvent.getId()}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Reply"
|
|
>
|
|
<Icon src={Icons.ReplyArrow} size="100" />
|
|
</IconButton>
|
|
{!isThreadedMessage && (
|
|
<IconButton
|
|
onClick={(ev) => onReplyClick(ev, true)}
|
|
data-event-id={mEvent.getId()}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Reply in thread"
|
|
>
|
|
<Icon src={Icons.ThreadPlus} size="100" />
|
|
</IconButton>
|
|
)}
|
|
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
|
<IconButton
|
|
onClick={() => onEditId(mEvent.getId())}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
|
|
>
|
|
<Icon src={Icons.Pencil} size="100" />
|
|
</IconButton>
|
|
)}
|
|
<PopOut
|
|
anchor={menuAnchor}
|
|
position="Bottom"
|
|
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Menu>
|
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
{canSendReaction && (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.SmilePlus} />}
|
|
radii="300"
|
|
onClick={handleAddReactions}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Add Reaction
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{relations && (
|
|
<MessageAllReactionItem
|
|
room={room}
|
|
relations={relations}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
|
radii="300"
|
|
data-event-id={mEvent.getId()}
|
|
onClick={(evt: any) => {
|
|
onReplyClick(evt);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Reply
|
|
</Text>
|
|
</MenuItem>
|
|
{!mEvent.isRedacted() && (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.ArrowRight} />}
|
|
radii="300"
|
|
onClick={() => {
|
|
setForwardOpen(true);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Forward
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{!mEvent.isRedacted() && mEvent.getId() && (
|
|
<MenuItem
|
|
size="300"
|
|
after={
|
|
<Icon
|
|
size="100"
|
|
src={Icons.Star}
|
|
filled={isBookmarked(mEvent.getId()!)}
|
|
/>
|
|
}
|
|
radii="300"
|
|
onClick={() => {
|
|
const eventId = mEvent.getId()!;
|
|
if (isBookmarked(eventId)) {
|
|
removeBookmark(eventId);
|
|
} else {
|
|
const content = mEvent.getContent();
|
|
const body: string =
|
|
(content?.body as string | undefined) ?? '';
|
|
addBookmark({
|
|
roomId: room.roomId,
|
|
eventId,
|
|
savedAt: Date.now(),
|
|
previewText: body.slice(0, 120),
|
|
roomName: room.name,
|
|
});
|
|
}
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
{isBookmarked(mEvent.getId()!)
|
|
? 'Remove Bookmark'
|
|
: 'Bookmark Message'}
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{!mEvent.isRedacted() && mEvent.getId() && (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Clock} />}
|
|
radii="300"
|
|
onClick={() => {
|
|
setRemindOpen(true);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Remind Me
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{!isThreadedMessage && (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
|
radii="300"
|
|
data-event-id={mEvent.getId()}
|
|
onClick={(evt: any) => {
|
|
onReplyClick(evt, true);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Reply in Thread
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Pencil} />}
|
|
radii="300"
|
|
data-event-id={mEvent.getId()}
|
|
onClick={() => {
|
|
onEditId(mEvent.getId());
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
{!hideReadReceipts && (
|
|
<MessageReadReceiptItem
|
|
room={room}
|
|
eventId={mEvent.getId() ?? ''}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
{showDeveloperTools && (
|
|
<MessageSourceCodeItem
|
|
room={room}
|
|
mEvent={mEvent}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
{canPinEvent && (
|
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
)}
|
|
</Box>
|
|
{(mEvent.status === EventStatus.NOT_SENT ||
|
|
mEvent.status === EventStatus.CANCELLED) && (
|
|
<>
|
|
<Line size="300" />
|
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Send} />}
|
|
radii="300"
|
|
onClick={() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(mx as any).resendEvent(mEvent, room);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Retry Send
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Cross} />}
|
|
radii="300"
|
|
onClick={() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(mx as any).cancelPendingEvent(mEvent);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<Text
|
|
className={css.MessageMenuItemText}
|
|
as="span"
|
|
size="T300"
|
|
truncate
|
|
>
|
|
Cancel Message
|
|
</Text>
|
|
</MenuItem>
|
|
</Box>
|
|
</>
|
|
)}
|
|
{((!mEvent.isRedacted() && canDelete) ||
|
|
mEvent.getSender() !== mx.getUserId()) && (
|
|
<>
|
|
<Line size="300" />
|
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
{!mEvent.isRedacted() && canDelete && (
|
|
<MessageDeleteItem
|
|
room={room}
|
|
mEvent={mEvent}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
{mEvent.getSender() !== mx.getUserId() && (
|
|
<MessageReportItem
|
|
room={room}
|
|
mEvent={mEvent}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Menu>
|
|
</FocusTrap>
|
|
}
|
|
>
|
|
<IconButton
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
onClick={handleOpenMenu}
|
|
aria-label="More options"
|
|
aria-expanded={!!menuAnchor}
|
|
aria-haspopup="menu"
|
|
>
|
|
<Icon src={Icons.VerticalDots} size="100" />
|
|
</IconButton>
|
|
</PopOut>
|
|
</Box>
|
|
</Menu>
|
|
</div>
|
|
)}
|
|
{messageLayout === MessageLayout.Compact && (
|
|
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
|
{msgContentJSX}
|
|
</CompactLayout>
|
|
)}
|
|
{messageLayout === MessageLayout.Bubble && (
|
|
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
|
{msgContentJSX}
|
|
</BubbleLayout>
|
|
)}
|
|
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
|
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
|
{headerJSX}
|
|
{msgContentJSX}
|
|
</ModernLayout>
|
|
)}
|
|
{forwardOpen && (
|
|
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
|
|
)}
|
|
{remindOpen && mEvent.getId() && (
|
|
<RemindMeDialog
|
|
roomId={room.roomId}
|
|
eventId={mEvent.getId()!}
|
|
previewText={(mEvent.getContent()?.body as string | undefined)?.slice(0, 120) ?? ''}
|
|
onClose={() => setRemindOpen(false)}
|
|
/>
|
|
)}
|
|
</MessageBase>
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
export type EventProps = {
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
highlight: boolean;
|
|
canDelete?: boolean;
|
|
messageSpacing: MessageSpacing;
|
|
hideReadReceipts?: boolean;
|
|
showDeveloperTools?: boolean;
|
|
};
|
|
export const Event = React.memo(
|
|
as<'div', EventProps>(
|
|
(
|
|
{
|
|
className,
|
|
room,
|
|
mEvent,
|
|
highlight,
|
|
canDelete,
|
|
messageSpacing,
|
|
hideReadReceipts,
|
|
showDeveloperTools,
|
|
children,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const [hover, setHover] = useState(false);
|
|
const mx = useMatrixClient();
|
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const stateEvent = typeof mEvent.getStateKey() === 'string';
|
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
|
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
|
const tag = (evt.target as any).tagName;
|
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
|
evt.preventDefault();
|
|
setMenuAnchor({
|
|
x: evt.clientX,
|
|
y: evt.clientY,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
};
|
|
|
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
|
|
setMenuAnchor(target.getBoundingClientRect());
|
|
};
|
|
|
|
const closeMenu = () => {
|
|
setMenuAnchor(undefined);
|
|
};
|
|
|
|
return (
|
|
<MessageBase
|
|
className={classNames(css.MessageBase, className)}
|
|
tabIndex={0}
|
|
space={messageSpacing}
|
|
autoCollapse
|
|
highlight={highlight}
|
|
selected={!!menuAnchor}
|
|
{...props}
|
|
{...hoverProps}
|
|
{...focusWithinProps}
|
|
ref={ref}
|
|
>
|
|
{(hover || !!menuAnchor) && (
|
|
<div className={css.MessageOptionsBase}>
|
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
|
<Box gap="100">
|
|
<PopOut
|
|
anchor={menuAnchor}
|
|
position="Bottom"
|
|
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
|
content={
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setMenuAnchor(undefined),
|
|
clickOutsideDeactivates: true,
|
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Menu {...props} ref={ref}>
|
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
{!hideReadReceipts && (
|
|
<MessageReadReceiptItem
|
|
room={room}
|
|
eventId={mEvent.getId() ?? ''}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
{showDeveloperTools && (
|
|
<MessageSourceCodeItem
|
|
room={room}
|
|
mEvent={mEvent}
|
|
onClose={closeMenu}
|
|
/>
|
|
)}
|
|
<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-label="More options"
|
|
aria-expanded={!!menuAnchor}
|
|
aria-haspopup="menu"
|
|
>
|
|
<Icon src={Icons.VerticalDots} size="100" />
|
|
</IconButton>
|
|
</PopOut>
|
|
</Box>
|
|
</Menu>
|
|
</div>
|
|
)}
|
|
<div onContextMenu={handleContextMenu}>{children}</div>
|
|
</MessageBase>
|
|
);
|
|
},
|
|
),
|
|
);
|