From 5a9f6d938a4852ff47cb14ed64b2e754966eab7e Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 11:35:07 +0100 Subject: [PATCH] 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. --- src/data/app-data.ts | 7 ++- .../(partials)/copy-message-button.tsx | 55 +++++++++++++++++++ .../[bot_id]/(partials)/message-actions.tsx | 30 ++++++---- .../bots/[bot_id]/(partials)/user-message.tsx | 11 +++- src/utils/copy-text.ts | 49 +++++++++++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/copy-message-button.tsx create mode 100644 src/utils/copy-text.ts diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 74b319f..59aa8c7 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -62,7 +62,7 @@ export const AppData = { * them. */ 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, AuthExpiryDays: 60, APICrudBasePath: "/api/admin/crud", @@ -395,6 +395,11 @@ export const AppData = { * Most bots or workspaces the composer's mention menu lists at once. */ 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. */ diff --git a/src/pages/admin/bots/[bot_id]/(partials)/copy-message-button.tsx b/src/pages/admin/bots/[bot_id]/(partials)/copy-message-button.tsx new file mode 100644 index 0000000..1a69d54 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/copy-message-button.tsx @@ -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 | 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 ( + void onCopy()} + > + {copied ? ( + + ) : ( + + )} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-actions.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-actions.tsx index b4143f5..00d0344 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-actions.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-actions.tsx @@ -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 ( - onReply({ message })} - > - - - + + {message.id ? ( + <> + onReply({ message })} + > + + + + + ) : null} ); } diff --git a/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx b/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx index 6235121..2aa32e5 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx @@ -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 ? ( + + + + ) : null} ); } diff --git a/src/utils/copy-text.ts b/src/utils/copy-text.ts new file mode 100644 index 0000000..295d003 --- /dev/null +++ b/src/utils/copy-text.ts @@ -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 { + 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(); + } +}