All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
1451 lines
48 KiB
TypeScript
1451 lines
48 KiB
TypeScript
import { Avatar, Box, Button, Dialog, Header, IconButton, Input, Line, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOut, RectCords, Spinner, Text, as, color, config } from 'folds';
|
|
import { Icon, Icons } from '../../../components/icons';
|
|
import React, {
|
|
FormEventHandler,
|
|
MouseEventHandler,
|
|
ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
} from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { useHover, useFocusWithin } from 'react-aria';
|
|
import { MatrixEvent, Room } 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 {
|
|
canEditEvent,
|
|
getEventEdits,
|
|
getMemberAvatarMxc,
|
|
getMemberDisplayName,
|
|
} from '../../../utils/room';
|
|
import {
|
|
getCanonicalAliasOrRoomId,
|
|
getMxIdLocalPart,
|
|
isRoomAlias,
|
|
mxcUrlToHttp,
|
|
} from '../../../utils/matrix';
|
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
|
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
|
|
import { emojis } from '../../../plugins/emoji';
|
|
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 { 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 { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
|
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 { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
|
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
|
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
|
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
|
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
|
|
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
|
|
|
type MessageQuickReactionsProps = {
|
|
onReaction: ReactionHandler;
|
|
};
|
|
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
|
({ onReaction, ...props }, ref) => {
|
|
const { getTopEmojis } = useEmojiUsage();
|
|
const topShortcodes = getTopEmojis(4);
|
|
const mostUsedEmojis = topShortcodes
|
|
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
|
|
.filter((e): e is IEmoji => e !== undefined);
|
|
|
|
if (mostUsedEmojis.length === 0) return <span />;
|
|
return (
|
|
<>
|
|
<Box
|
|
style={{ padding: config.space.S200 }}
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
gap="200"
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
{mostUsedEmojis.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" />
|
|
</>
|
|
);
|
|
}
|
|
);
|
|
|
|
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 mx = useMatrixClient();
|
|
|
|
const handleCopy = () => {
|
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
const eventId = mEvent.getId();
|
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
if (!eventId) return;
|
|
copyToClipboard(getMatrixToRoomEvent(roomIdOrAlias, eventId, viaServers));
|
|
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 MessageCopyRawTextItem = as<
|
|
'button',
|
|
{
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ mEvent, onClose, ...props }, ref) => {
|
|
const handleCopy = () => {
|
|
const content = mEvent.getContent();
|
|
const rawText = content.body ?? '';
|
|
if (rawText) {
|
|
copyToClipboard(rawText);
|
|
}
|
|
onClose?.();
|
|
};
|
|
|
|
// Only show for text messages
|
|
const content = mEvent.getContent();
|
|
if (!content.body || mEvent.isRedacted()) return null;
|
|
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.File} />}
|
|
radii="300"
|
|
onClick={handleCopy}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
Copy Raw Text
|
|
</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);
|
|
}
|
|
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, 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">
|
|
<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={(evt) => {
|
|
if (evt.shiftKey) {
|
|
const eventId = mEvent.getId();
|
|
if (eventId) {
|
|
mx.redactEvent(room.roomId, eventId);
|
|
onClose?.();
|
|
}
|
|
} else {
|
|
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">
|
|
<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;
|
|
};
|
|
export const Message = 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,
|
|
children,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const direct = useIsDirectRoom();
|
|
const senderId = mEvent.getSender() ?? '';
|
|
const senderPresence = useUserPresence(senderId);
|
|
const { assetUrls } = useOtherUserCollectibles(senderId);
|
|
const nameplateUrl = pickNameplateUrl(assetUrls);
|
|
const nameplateIsVideo = Boolean(
|
|
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
|
);
|
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
|
|
|
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 [shiftHeld, setShiftHeld] = useState(false);
|
|
|
|
// Track shift key state
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') setShiftHeld(true);
|
|
};
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') setShiftHeld(false);
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('keyup', handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('keyup', handleKeyUp);
|
|
};
|
|
}, []);
|
|
|
|
const senderDisplayName =
|
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
|
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
|
|
|
// Get custom user color from avatar metadata (takes priority)
|
|
const customUserColor = useOtherUserColor(senderId, room);
|
|
|
|
const tagColor = memberPowerTag?.color
|
|
? accessibleTagColors?.get(memberPowerTag.color)
|
|
: undefined;
|
|
const tagIconSrc = memberPowerTag?.icon
|
|
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
|
: undefined;
|
|
|
|
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
|
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
|
const showNameplate = direct && hover && Boolean(nameplateUrl);
|
|
|
|
const headerJSX = !collapse && (
|
|
<Box
|
|
gap="300"
|
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
|
justifyContent="SpaceBetween"
|
|
alignItems="Center"
|
|
grow="Yes"
|
|
data-message-header=""
|
|
>
|
|
<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" className={css.MessageNameplateAnchor}>
|
|
<Box shrink="No" gap="100" alignItems="Center">
|
|
{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>
|
|
{showNameplate && (
|
|
<div className={css.MessageNameplateWrap}>
|
|
{nameplateIsVideo ? (
|
|
<video
|
|
className={css.MessageNameplateOverlay}
|
|
src={nameplateUrl}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
/>
|
|
) : (
|
|
<img className={css.MessageNameplateOverlay} src={nameplateUrl} alt="" draggable={false} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
const presenceClass = senderPresence?.presence === Presence.Online
|
|
? css.MessageAvatarOnline
|
|
: senderPresence?.presence === Presence.Unavailable
|
|
? css.MessageAvatarUnavailable
|
|
: senderPresence?.presence === Presence.Offline
|
|
? css.MessageAvatarOffline
|
|
: undefined;
|
|
|
|
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
|
<AvatarBase
|
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
|
>
|
|
<div className={css.MessageAvatarStack}>
|
|
<Avatar
|
|
className={classNames(
|
|
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
|
|
!avatarDecorationUrl && presenceClass
|
|
)}
|
|
radii={avatarDecorationUrl ? 'Pill' : undefined}
|
|
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>
|
|
{avatarDecorationUrl && (
|
|
<img
|
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
|
src={avatarDecorationUrl}
|
|
alt=""
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
</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}
|
|
</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' || tag.toLowerCase() === 'img')) 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,
|
|
})}
|
|
tabIndex={0}
|
|
space={messageSpacing}
|
|
collapse={collapse}
|
|
highlight={highlight}
|
|
selected={!!menuAnchor || !!emojiBoardAnchor}
|
|
{...props}
|
|
{...hoverProps}
|
|
{...focusWithinProps}
|
|
ref={ref}
|
|
>
|
|
<div
|
|
className={css.MessageContentLayer}
|
|
data-message-nameplate-readable={showNameplate ? '' : undefined}
|
|
>
|
|
{!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-pressed={!!emojiBoardAnchor}
|
|
>
|
|
<Icon src={Icons.SmilePlus} size="100" />
|
|
</IconButton>
|
|
</PopOut>
|
|
)}
|
|
<IconButton
|
|
onClick={onReplyClick}
|
|
data-event-id={mEvent.getId()}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<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"
|
|
>
|
|
<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>
|
|
)}
|
|
{mEvent.getContent().body && !mEvent.isRedacted() && (
|
|
<IconButton
|
|
onClick={() => {
|
|
const rawText = mEvent.getContent().body ?? '';
|
|
if (rawText) copyToClipboard(rawText);
|
|
}}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
title="Copy Raw Text"
|
|
>
|
|
<Icon src={Icons.File} size="100" />
|
|
</IconButton>
|
|
)}
|
|
{shiftHeld && canDelete && (
|
|
<IconButton
|
|
onClick={() => {
|
|
const eventId = mEvent.getId();
|
|
if (eventId) {
|
|
mx.redactEvent(room.roomId, eventId);
|
|
}
|
|
}}
|
|
variant="Critical"
|
|
size="300"
|
|
radii="300"
|
|
title="Delete Message (Shift held)"
|
|
>
|
|
<Icon src={Icons.Delete} size="100" />
|
|
</IconButton>
|
|
)}
|
|
<PluginButtonSlot location="message-actions" />
|
|
<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) => {
|
|
onReactionToggle(mEvent.getId()!, 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
|
|
>
|
|
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>
|
|
{!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>
|
|
)}
|
|
{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
|
|
>
|
|
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} />
|
|
<MessageCopyRawTextItem 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>
|
|
)}
|
|
{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>
|
|
)}
|
|
</div>
|
|
</MessageBase>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type EventProps = {
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
highlight: boolean;
|
|
canDelete?: boolean;
|
|
messageSpacing: MessageSpacing;
|
|
hideReadReceipts?: boolean;
|
|
showDeveloperTools?: boolean;
|
|
};
|
|
export const Event = as<'div', EventProps>(
|
|
(
|
|
{
|
|
className,
|
|
room,
|
|
mEvent,
|
|
highlight,
|
|
canDelete,
|
|
messageSpacing,
|
|
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 any).tagName;
|
|
if (typeof tag === 'string' && (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'img')) 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">
|
|
<PluginButtonSlot location="message-actions" />
|
|
<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} />
|
|
<MessageCopyRawTextItem 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>
|
|
);
|
|
}
|
|
);
|