Copy a message to the clipboard from the chat

Bot and user messages get a quiet copy button under them: the copy icon becomes a check for a moment after it copies the message's text as stored, markdown and refs included. A message still on its way to the server can be copied too, though reply and reactions wait for its id. The async clipboard API is used where it exists and a hidden textarea where a plain HTTP install has none.
This commit is contained in:
2026-10-22 11:35:07 +01:00
parent 080f8e9abc
commit 5a9f6d938a
5 changed files with 139 additions and 13 deletions
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { AppData } from "@/src/data/app-data";
import copyText from "@/src/utils/copy-text";
import MessageActionButton from "./message-action-button";
type Props = {
text: string;
};
/**
* Copies a message's text as it is stored (markdown and all) and swaps
* its icon for a check for a moment, so it says it worked without a
* notification. Renders nothing for a message with no text.
*/
export default function CopyMessageButton({ text }: Props) {
const [copied, setCopied] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
if (!text.trim()) return null;
const onCopy = async () => {
const worked = await copyText({ text });
if (!worked) return;
if (timeout.current) clearTimeout(timeout.current);
setCopied(true);
timeout.current = setTimeout(
() => setCopied(false),
AppData.CopyConfirmationMs,
);
};
return (
<MessageActionButton
title={copied ? "Copied" : "Copy"}
aria_label={copied ? "Copied to the clipboard" : "Copy this message"}
onClick={() => void onCopy()}
>
{copied ? (
<Check size={14} aria-hidden />
) : (
<Copy size={14} aria-hidden />
)}
</MessageActionButton>
);
}
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import { Reply } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import CopyMessageButton from "./copy-message-button";
import MessageActionButton from "./message-action-button";
import MessageReactionPicker from "./message-reaction-picker";
@@ -11,23 +12,32 @@ type Props = {
};
/**
* A message's reply and reaction actions, shown under it.
* A message's copy, reply and reaction actions, shown under it. A message
* still on its way to the server has no id yet, so it can only be copied.
*/
export default function MessageActions({ message }: Props) {
const { onReply, onReact } = useContext(BotMessageActionsContext);
if (!message.id) return null;
if (!message.id && !message.text?.trim()) return null;
return (
<Row className="gap-3! items-center">
<MessageActionButton
title="Reply"
aria_label="Reply to this message"
onClick={() => onReply({ message })}
>
<Reply size={14} aria-hidden />
</MessageActionButton>
<MessageReactionPicker message_id={message.id} onReact={onReact} />
<CopyMessageButton text={message.text || ""} />
{message.id ? (
<>
<MessageActionButton
title="Reply"
aria_label="Reply to this message"
onClick={() => onReply({ message })}
>
<Reply size={14} aria-hidden />
</MessageActionButton>
<MessageReactionPicker
message_id={message.id}
onReact={onReact}
/>
</>
) : null}
</Row>
);
}
@@ -1,6 +1,8 @@
import { memo } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import CopyMessageButton from "./copy-message-button";
import MessageAttachments from "./message-attachments";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
@@ -14,8 +16,8 @@ type Props = {
/**
* One user message: the quoted message it answers, its files, then its
* text (a file-only message has no bubble) and the bot's reactions to it.
* The user doesn't reply to or react to their own messages. Memoized,
* like `BotMessageLine`.
* The user doesn't reply to or react to their own messages, but they can
* copy one. Memoized, like `BotMessageLine`.
*/
function UserMessage({ message, bot_name }: Props) {
return (
@@ -35,6 +37,11 @@ function UserMessage({ message, bot_name }: Props) {
reactions={message.reactions}
/>
) : null}
{message.text ? (
<Row className="gap-3! items-center">
<CopyMessageButton text={message.text} />
</Row>
) : null}
</Stack>
);
}