From 133e6b87c9880002c068d7b8c0f368f3cb22fe4a Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 12:33:48 +0100 Subject: [PATCH] Put a user message's copy button beside its timestamp The copy button sat on its own row above the group's time, costing a line of vertical space for nothing. It now shares that row, left of the timestamp, the way a bot message's actions already do. A file-only message leaves no gap: only messages with text get a button, picked by copyableMessages. --- .../[bot_id]/(partials)/user-message-copy.tsx | 15 +++++++++++ .../(partials)/user-message-group.tsx | 17 +++++++++--- .../bots/[bot_id]/(partials)/user-message.tsx | 10 ++----- .../(utils)/copyable-messages.test.ts | 27 +++++++++++++++++++ .../[bot_id]/(utils)/copyable-messages.ts | 14 ++++++++++ 5 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/user-message-copy.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.test.ts create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/copyable-messages.ts 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())); +}