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