chatbot-template/components/chat/message-actions.tsx

208 lines
5.5 KiB
TypeScript
Raw Normal View History

import equal from "fast-deep-equal";
import { memo } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { useCopyToClipboard } from "usehooks-ts";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import {
MessageAction as Action,
MessageActions as Actions,
} from "../ai-elements/message";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
chatId,
message,
vote,
isLoading,
onEdit,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
onEdit?: () => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) {
return null;
}
const textFromParts = message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
const handleCopy = async () => {
if (!textFromParts) {
toast.error("There's no text to copy!");
return;
}
await copyToClipboard(textFromParts);
toast.success("Copied to clipboard!");
};
if (message.role === "user") {
return (
<Actions className="-mr-0.5 justify-end opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<div className="flex items-center gap-0.5">
{onEdit && (
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
data-testid="message-edit-button"
onClick={onEdit}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
</div>
</Actions>
);
}
return (
<Actions className="-ml-0.5 opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<Action
className="text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
const upvote = fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`,
{
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "up",
}),
}
);
toast.promise(upvote, {
loading: "Upvoting Response...",
success: () => {
mutate<Vote[]>(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false }
);
return "Upvoted Response!";
},
error: "Failed to upvote response.",
});
}}
tooltip="Upvote Response"
>
<ThumbUpIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {
const downvote = fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`,
{
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "down",
}),
}
);
toast.promise(downvote, {
loading: "Downvoting Response...",
success: () => {
mutate<Vote[]>(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false }
);
return "Downvoted Response!";
},
error: "Failed to downvote response.",
});
}}
tooltip="Downvote Response"
>
<ThumbDownIcon />
</Action>
</Actions>
);
}
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
return true;
}
);