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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,15 +12,19 @@ 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">
|
||||
<CopyMessageButton text={message.text || ""} />
|
||||
{message.id ? (
|
||||
<>
|
||||
<MessageActionButton
|
||||
title="Reply"
|
||||
aria_label="Reply to this message"
|
||||
@@ -27,7 +32,12 @@ export default function MessageActions({ message }: Props) {
|
||||
>
|
||||
<Reply size={14} aria-hidden />
|
||||
</MessageActionButton>
|
||||
<MessageReactionPicker message_id={message.id} onReact={onReact} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user