Give each user message its own copy, reply and time
Two prompts sent close together (as while a bot is still answering) shared one row under their group: every copy button bunched beside the last message's time. Each user message now has its own row, with its copy button, a new Reply button (once it is saved) and its own time. copyableMessages is gone with the shared row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -5,8 +5,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One message's copy button, in the row under a user group beside its
|
* One message's copy button, in the row under it beside its timestamp. A
|
||||||
* timestamp. A message with no text renders nothing at all.
|
* message with no text renders nothing at all.
|
||||||
*/
|
*/
|
||||||
export default function UserMessageCopy({ text }: Props) {
|
export default function UserMessageCopy({ text }: Props) {
|
||||||
if (!text.trim()) return null;
|
if (!text.trim()) return null;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import Row from "@moduletrace/twui/layout/Row";
|
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import type { BotWithLastMessage } from "@/src/types";
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
import copyableMessages from "../(utils)/copyable-messages";
|
|
||||||
import type { MessageGroup } from "../(utils)/group-messages";
|
import type { MessageGroup } from "../(utils)/group-messages";
|
||||||
import UserMessage from "./user-message";
|
import UserMessage from "./user-message";
|
||||||
import UserMessageCopy from "./user-message-copy";
|
|
||||||
import GroupTimestamp from "./group-timestamp";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -13,24 +9,16 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-aligned ink bubbles (each message's files above it), then one
|
* Right-aligned ink bubbles (each message's files above it), each with
|
||||||
* row under the group: a copy button per message with text, left of the
|
* its own copy button and time under it, so two messages sent close
|
||||||
* group's time.
|
* together never share one.
|
||||||
*/
|
*/
|
||||||
export default function UserMessageGroup({ bot, group }: Props) {
|
export default function UserMessageGroup({ bot, group }: Props) {
|
||||||
const copyable = copyableMessages({ messages: group.messages });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full gap-1! items-end">
|
<Stack className="w-full gap-1! items-end">
|
||||||
{group.messages.map((message) => (
|
{group.messages.map((message) => (
|
||||||
<UserMessage key={message.id} message={message} bot_name={bot.name || "the bot"} />
|
<UserMessage key={message.id} message={message} bot_name={bot.name || "the bot"} />
|
||||||
))}
|
))}
|
||||||
<Row className="gap-3! items-center">
|
|
||||||
{copyable.map((message) => (
|
|
||||||
<UserMessageCopy key={message.id} text={message.text || ""} />
|
|
||||||
))}
|
|
||||||
<GroupTimestamp at={group.at} />
|
|
||||||
</Row>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { Reply } from "lucide-react";
|
||||||
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
|
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||||
|
import MessageActionButton from "./message-action-button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: BotMessageWithAttachments;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply to one of the user's own messages, beside its copy button. A
|
||||||
|
* message still on its way has only a temporary (negative) id, so it gets
|
||||||
|
* no button until the saved one replaces it.
|
||||||
|
*/
|
||||||
|
export default function UserMessageReply({ message }: Props) {
|
||||||
|
const { onReply } = useContext(BotMessageActionsContext);
|
||||||
|
|
||||||
|
if (!(Number(message.id) > 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageActionButton
|
||||||
|
title="Reply"
|
||||||
|
aria_label="Reply to this message"
|
||||||
|
onClick={() => onReply({ message })}
|
||||||
|
>
|
||||||
|
<Reply size={14} aria-hidden />
|
||||||
|
</MessageActionButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
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 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";
|
||||||
import UserBubble from "./user-bubble";
|
import UserBubble from "./user-bubble";
|
||||||
|
import UserMessageCopy from "./user-message-copy";
|
||||||
|
import UserMessageReply from "./user-message-reply";
|
||||||
|
import GroupTimestamp from "./group-timestamp";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
message: BotMessageWithAttachments;
|
message: BotMessageWithAttachments;
|
||||||
@@ -14,9 +18,9 @@ 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, but they can
|
* The user doesn't react to their own messages, but they can copy one or
|
||||||
* copy one: the group's copy buttons sit beside its timestamp, in
|
* reply to it: those buttons sit beside its own time. Memoized, like
|
||||||
* `UserMessageGroup`. Memoized, like `BotMessageLine`.
|
* `BotMessageLine`.
|
||||||
*/
|
*/
|
||||||
function UserMessage({ message, bot_name }: Props) {
|
function UserMessage({ message, bot_name }: Props) {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +40,11 @@ function UserMessage({ message, bot_name }: Props) {
|
|||||||
reactions={message.reactions}
|
reactions={message.reactions}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Row className="gap-3! items-center">
|
||||||
|
<UserMessageCopy text={message.text || ""} />
|
||||||
|
<UserMessageReply message={message} />
|
||||||
|
<GroupTimestamp at={Number(message.created_at || 0)} />
|
||||||
|
</Row>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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()));
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user