CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1728 lines
58 KiB
TypeScript
1728 lines
58 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 { useAtom } from 'jotai';
|
|
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,
|
|
trimReplyFromBody,
|
|
} from '../../../utils/room';
|
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
|
import { messageAriaLabel } from '../../../utils/a11y';
|
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
|
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
|
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
|
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 { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
|
|
import { getOriginBaseUrl } from '../../../pages/pathUtils';
|
|
import { useClientConfig } from '../../../hooks/useClientConfig';
|
|
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 { useLongPress } from '../../../hooks/useLongPress';
|
|
import { ActionSheet } from '../../../components/action-sheet';
|
|
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 modalStyle = useModalStyle(360);
|
|
|
|
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" style={modalStyle}>
|
|
<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>
|
|
);
|
|
});
|
|
|
|
// [Gitea #130] Same as Copy Link but a direct link into THIS deployment, for
|
|
// recipients who use Lotus (matrix.to cannot be pointed here).
|
|
export const MessageCopyLotusLinkItem = as<
|
|
'button',
|
|
{
|
|
room: Room;
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
|
const { hashRouter } = useClientConfig();
|
|
const handleCopy = () => {
|
|
const eventId = mEvent.getId();
|
|
if (!eventId) return;
|
|
copyToClipboard(
|
|
getLotusRoomPermalink(
|
|
getOriginBaseUrl(hashRouter),
|
|
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 Lotus Link
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
});
|
|
|
|
// Copies the message's plain-text body (reply fallback stripped) to the
|
|
// clipboard. Renders nothing for events without a usable text body (e.g. media
|
|
// without a caption), so the caller can list it unconditionally.
|
|
export const MessageCopyTextItem = as<
|
|
'button',
|
|
{
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ mEvent, onClose, ...props }, ref) => {
|
|
const content = mEvent.getContent();
|
|
// Text-only: for media the `body` is the filename (or caption). We don't want
|
|
// "Copy Text" to copy a filename, so gate to textual message types.
|
|
const msgtype = content.msgtype;
|
|
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
|
|
const rawBody = typeof content.body === 'string' ? content.body : '';
|
|
const body = trimReplyFromBody(rawBody).trim();
|
|
|
|
if (!isTextual || !body) return null;
|
|
|
|
const handleCopy = () => {
|
|
copyToClipboard(body);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Text} />}
|
|
radii="300"
|
|
onClick={handleCopy}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
Copy Text
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
});
|
|
|
|
export const MessageTranslateItem = as<
|
|
'button',
|
|
{
|
|
mEvent: MatrixEvent;
|
|
onClose?: () => void;
|
|
}
|
|
>(({ mEvent, onClose, ...props }, ref) => {
|
|
const content = mEvent.getContent();
|
|
const msgtype = content.msgtype;
|
|
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
|
|
const rawBody = typeof content.body === 'string' ? content.body : '';
|
|
const body = trimReplyFromBody(rawBody).trim();
|
|
const eventId = mEvent.getId() ?? '';
|
|
|
|
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
|
|
|
|
// On-device translation is Chromium-desktop only; hide the action entirely
|
|
// where the engine can't run, and for non-textual/empty messages.
|
|
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
|
|
|
|
const handleToggle = () => {
|
|
setActive((a) => !a);
|
|
onClose?.();
|
|
};
|
|
|
|
return (
|
|
<MenuItem
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Globe} />}
|
|
radii="300"
|
|
onClick={handleToggle}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
|
{active ? 'Show Original' : 'Translate'}
|
|
</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 modalStyle = useModalStyle(480);
|
|
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" style={modalStyle}>
|
|
<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}
|
|
aria-label={`${senderDisplayName}, open profile`}
|
|
>
|
|
<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>
|
|
);
|
|
|
|
// [Gitea #166] Touch: a long-press (or Android's contextmenu) opens the
|
|
// actions as a bottom sheet instead of a hover bar + anchored popout.
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const {
|
|
coarse: coarsePointer,
|
|
handlers: longPressHandlers,
|
|
suppressContextMenu,
|
|
} = useLongPress(
|
|
useCallback(() => {
|
|
if (edit) return;
|
|
// The press itself starts a text/image selection on touch browsers;
|
|
// that is a by-product, not the user's intent — drop it.
|
|
window.getSelection()?.removeAllRanges();
|
|
setSheetOpen(true);
|
|
}, [edit]),
|
|
);
|
|
|
|
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();
|
|
if (coarsePointer) {
|
|
// Android fires contextmenu for the same long-press we already handled.
|
|
if (!suppressContextMenu.current) setSheetOpen(true);
|
|
return;
|
|
}
|
|
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);
|
|
setSheetOpen(false);
|
|
};
|
|
|
|
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;
|
|
|
|
// The full action menu, shared by the desktop PopOut and the touch
|
|
// bottom sheet (#166).
|
|
const menuJSX = (
|
|
<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) ?? '';
|
|
// For E2EE rooms useBookmarks strips the text
|
|
// fields before persisting (account data is
|
|
// server-readable); the panel resolves them live.
|
|
addBookmark({
|
|
roomId: room.roomId,
|
|
eventId,
|
|
savedAt: Date.now(),
|
|
previewText: body.slice(0, 120),
|
|
roomName: room.name,
|
|
senderName: senderDisplayName,
|
|
});
|
|
}
|
|
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} />
|
|
)}
|
|
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageCopyLotusLinkItem 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>
|
|
);
|
|
|
|
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,
|
|
dateFormatString,
|
|
})
|
|
: 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,
|
|
}}
|
|
>
|
|
{menuJSX}
|
|
</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}
|
|
{...longPressHandlers}
|
|
>
|
|
{msgContentJSX}
|
|
</CompactLayout>
|
|
)}
|
|
{messageLayout === MessageLayout.Bubble && (
|
|
<BubbleLayout
|
|
before={avatarJSX}
|
|
header={headerJSX}
|
|
onContextMenu={handleContextMenu}
|
|
{...longPressHandlers}
|
|
>
|
|
{msgContentJSX}
|
|
</BubbleLayout>
|
|
)}
|
|
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
|
<ModernLayout
|
|
before={avatarJSX}
|
|
onContextMenu={handleContextMenu}
|
|
{...longPressHandlers}
|
|
>
|
|
{headerJSX}
|
|
{msgContentJSX}
|
|
</ModernLayout>
|
|
)}
|
|
{sheetOpen && (
|
|
<ActionSheet open onClose={() => setSheetOpen(false)} aria-label="Message actions">
|
|
{canSendReaction && (
|
|
<Box alignItems="Center" justifyContent="Center" gap="200">
|
|
<MessageQuickReactions
|
|
onReaction={(key, shortcode) => {
|
|
onReactionToggle(mEvent.getId()!, key, shortcode);
|
|
setSheetOpen(false);
|
|
}}
|
|
/>
|
|
<IconButton
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="Pill"
|
|
aria-label="Add reaction"
|
|
onClick={() => {
|
|
setSheetOpen(false);
|
|
// the emoji board anchors to the message; open it after the sheet is gone
|
|
setTimeout(() => {
|
|
const el = document.querySelector<HTMLElement>(
|
|
`[data-message-id="${mEvent.getId()}"]`,
|
|
);
|
|
setEmojiBoardAnchor(
|
|
el?.getBoundingClientRect() ?? {
|
|
x: 0,
|
|
y: window.innerHeight / 2,
|
|
width: window.innerWidth,
|
|
height: 0,
|
|
},
|
|
);
|
|
}, 50);
|
|
}}
|
|
>
|
|
<Icon src={Icons.SmilePlus} size="100" />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
{menuJSX}
|
|
</ActionSheet>
|
|
)}
|
|
{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}
|
|
/>
|
|
)}
|
|
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
<MessageCopyLotusLinkItem
|
|
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>
|
|
);
|
|
},
|
|
),
|
|
);
|