feat(message): add "Copy Text" context-menu action

The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:17:52 -04:00
co-authored by Claude Opus 4.8
parent df8afe4410
commit 12aa49f054
2 changed files with 44 additions and 0 deletions
+4
View File
@@ -683,6 +683,10 @@ The indicator is hidden once the server confirms the event (when the internal st
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
### Copy Message Text
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
### Draft Persistence
- Composer drafts are stored in `localStorage` keyed by `roomId`
+40
View File
@@ -56,6 +56,7 @@ import {
getMemberAvatarMxc,
getMemberName,
sendStateEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
@@ -411,6 +412,43 @@ export const MessageCopyLinkItem = as<
);
});
// 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();
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
if (!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 MessagePinItem = as<
'button',
{
@@ -1284,6 +1322,7 @@ export const Message = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1513,6 +1552,7 @@ export const Event = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||