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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user