diff --git a/src/pages/admin/bots/[bot_id]/(partials)/user-message-copy.tsx b/src/pages/admin/bots/[bot_id]/(partials)/user-message-copy.tsx
new file mode 100644
index 0000000..d747880
--- /dev/null
+++ b/src/pages/admin/bots/[bot_id]/(partials)/user-message-copy.tsx
@@ -0,0 +1,15 @@
+import CopyMessageButton from "./copy-message-button";
+
+type Props = {
+ text: string;
+};
+
+/**
+ * One message's copy button, in the row under a user group beside its
+ * timestamp. A message with no text renders nothing at all.
+ */
+export default function UserMessageCopy({ text }: Props) {
+ if (!text.trim()) return null;
+
+ return ;
+}
diff --git a/src/pages/admin/bots/[bot_id]/(partials)/user-message-group.tsx b/src/pages/admin/bots/[bot_id]/(partials)/user-message-group.tsx
index a93e377..71e4089 100644
--- a/src/pages/admin/bots/[bot_id]/(partials)/user-message-group.tsx
+++ b/src/pages/admin/bots/[bot_id]/(partials)/user-message-group.tsx
@@ -1,7 +1,10 @@
+import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotWithLastMessage } from "@/src/types";
+import copyableMessages from "../(utils)/copyable-messages";
import type { MessageGroup } from "../(utils)/group-messages";
import UserMessage from "./user-message";
+import UserMessageCopy from "./user-message-copy";
import GroupTimestamp from "./group-timestamp";
type Props = {
@@ -10,16 +13,24 @@ type Props = {
};
/**
- * Right-aligned ink bubbles (each message's files above it), one
- * timestamp under the group.
+ * Right-aligned ink bubbles (each message's files above it), then one
+ * row under the group: a copy button per message with text, left of the
+ * group's time.
*/
export default function UserMessageGroup({ bot, group }: Props) {
+ const copyable = copyableMessages({ messages: group.messages });
+
return (
{group.messages.map((message) => (
))}
-
+
+ {copyable.map((message) => (
+
+ ))}
+
+
);
}
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 2aa32e5..b7fd91c 100644
--- a/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx
+++ b/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx
@@ -1,8 +1,6 @@
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";
@@ -17,7 +15,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, but they can
- * copy one. Memoized, like `BotMessageLine`.
+ * copy one: the group's copy buttons sit beside its timestamp, in
+ * `UserMessageGroup`. Memoized, like `BotMessageLine`.
*/
function UserMessage({ message, bot_name }: Props) {
return (
@@ -37,11 +36,6 @@ function UserMessage({ message, bot_name }: Props) {
reactions={message.reactions}
/>
) : null}
- {message.text ? (
-
-
-
- ) : null}
);
}
diff --git a/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.test.ts b/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.test.ts
new file mode 100644
index 0000000..b9f7f3a
--- /dev/null
+++ b/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test";
+import type { BotMessageWithAttachments } from "@/src/types";
+import copyableMessages from "./copyable-messages";
+
+function message(text?: string): BotMessageWithAttachments {
+ return { id: 1, author: "user", text } as BotMessageWithAttachments;
+}
+
+describe("copyableMessages", () => {
+ test("keeps the messages that have text", () => {
+ const kept = copyableMessages({
+ messages: [message("hello"), message(undefined), message("hi")],
+ });
+
+ expect(kept.map((one) => one.text)).toEqual(["hello", "hi"]);
+ });
+
+ test("drops text that is only whitespace", () => {
+ expect(copyableMessages({ messages: [message(" ")] })).toEqual([]);
+ });
+
+ test("a group with no text has nothing to copy", () => {
+ expect(copyableMessages({ messages: [message(), message()] })).toEqual(
+ [],
+ );
+ });
+});
diff --git a/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.ts b/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.ts
new file mode 100644
index 0000000..d1f986b
--- /dev/null
+++ b/src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.ts
@@ -0,0 +1,14 @@
+import type { BotMessageWithAttachments } from "@/src/types";
+
+/**
+ * The messages of a group that can be copied: a file-only message has no
+ * text, and its copy button renders nothing, so it would only leave a
+ * gap in the row.
+ */
+export default function copyableMessages({
+ messages,
+}: {
+ messages: BotMessageWithAttachments[];
+}): BotMessageWithAttachments[] {
+ return messages.filter((message) => Boolean(message.text?.trim()));
+}