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
+6 -1
View File
@@ -62,7 +62,7 @@ export const AppData = {
* them. * them.
*/ */
LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`, LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`,
LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`, // LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`,
LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60, LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
AuthExpiryDays: 60, AuthExpiryDays: 60,
APICrudBasePath: "/api/admin/crud", APICrudBasePath: "/api/admin/crud",
@@ -395,6 +395,11 @@ export const AppData = {
* Most bots or workspaces the composer's mention menu lists at once. * Most bots or workspaces the composer's mention menu lists at once.
*/ */
ComposerMentionMaxItems: 6, ComposerMentionMaxItems: 6,
/**
* How long a message's copy button shows a check after it copied the
* message, before it goes back to the copy icon.
*/
CopyConfirmationMs: 1500,
/** /**
* Most of a bot message read aloud; the rest is cut. * Most of a bot message read aloud; the rest is cut.
*/ */
@@ -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 { Reply } from "lucide-react";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context"; import { BotMessageActionsContext } from "../(data)/message-actions-context";
import CopyMessageButton from "./copy-message-button";
import MessageActionButton from "./message-action-button"; import MessageActionButton from "./message-action-button";
import MessageReactionPicker from "./message-reaction-picker"; 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) { export default function MessageActions({ message }: Props) {
const { onReply, onReact } = useContext(BotMessageActionsContext); const { onReply, onReact } = useContext(BotMessageActionsContext);
if (!message.id) return null; if (!message.id && !message.text?.trim()) return null;
return ( return (
<Row className="gap-3! items-center"> <Row className="gap-3! items-center">
<MessageActionButton <CopyMessageButton text={message.text || ""} />
title="Reply" {message.id ? (
aria_label="Reply to this message" <>
onClick={() => onReply({ message })} <MessageActionButton
> title="Reply"
<Reply size={14} aria-hidden /> aria_label="Reply to this message"
</MessageActionButton> onClick={() => onReply({ message })}
<MessageReactionPicker message_id={message.id} onReact={onReact} /> >
<Reply size={14} aria-hidden />
</MessageActionButton>
<MessageReactionPicker
message_id={message.id}
onReact={onReact}
/>
</>
) : null}
</Row> </Row>
); );
} }
@@ -1,6 +1,8 @@
import { memo } from "react"; import { memo } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import CopyMessageButton from "./copy-message-button";
import MessageAttachments from "./message-attachments"; import MessageAttachments from "./message-attachments";
import MessageReactions from "./message-reactions"; import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote"; import MessageReplyQuote from "./message-reply-quote";
@@ -14,8 +16,8 @@ type Props = {
/** /**
* One user message: the quoted message it answers, its files, then its * 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. * 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, * The user doesn't reply to or react to their own messages, but they can
* like `BotMessageLine`. * copy one. Memoized, like `BotMessageLine`.
*/ */
function UserMessage({ message, bot_name }: Props) { function UserMessage({ message, bot_name }: Props) {
return ( return (
@@ -35,6 +37,11 @@ function UserMessage({ message, bot_name }: Props) {
reactions={message.reactions} reactions={message.reactions}
/> />
) : null} ) : null}
{message.text ? (
<Row className="gap-3! items-center">
<CopyMessageButton text={message.text} />
</Row>
) : null}
</Stack> </Stack>
); );
} }
+49
View File
@@ -0,0 +1,49 @@
type Params = {
text: string;
};
/**
* Copies text to the clipboard and says whether it worked. The async
* clipboard API needs a secure context, so a plain HTTP address (a VPN or
* LAN install) falls back to copying from a temporary textarea.
*/
export default async function copyText({ text }: Params): Promise<boolean> {
if (!text) {
return false;
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to the textarea, which can still work.
}
}
return copyWithTextarea({ text });
}
/**
* The pre-clipboard-API way: select a hidden textarea and copy it. The
* element is off screen rather than hidden, since `display: none` is not
* selectable, and it is removed again either way.
*/
function copyWithTextarea({ text }: Params): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.className = "sr-only";
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
textarea.remove();
}
}