264 lines
7.9 KiB
TypeScript
264 lines
7.9 KiB
TypeScript
import { useIsMobile } from "@/hooks/useScreen";
|
|
import { cn } from "@/utils";
|
|
import { MessageSquareQuote, SendIcon } from "lucide-react";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import furinaAvatar from "@/assets/furina-avatar.webp";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/api";
|
|
import { ThreeDots } from "react-loader-spinner";
|
|
import Markdown from "react-markdown";
|
|
|
|
const ChatWindow = () => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const moveWindowStateRef = useRef({
|
|
isMoving: false,
|
|
x: 0,
|
|
y: 0,
|
|
startX: 0,
|
|
startY: 0,
|
|
});
|
|
const isMobile = useIsMobile();
|
|
const queryClient = useQueryClient();
|
|
const [isOpen, setOpen] = useState(false);
|
|
|
|
const { data: messages } = useQuery({
|
|
queryKey: ["chats"],
|
|
queryFn: () => api("/chats"),
|
|
});
|
|
|
|
const sendMessage = useMutation({
|
|
mutationFn: async (message: string) => {
|
|
return api("/chats", {
|
|
method: "POST",
|
|
body: JSON.stringify({ message }),
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
},
|
|
onMutate: async (data) => {
|
|
await queryClient.cancelQueries({ queryKey: ["chats"] });
|
|
const prevData = queryClient.getQueryData(["chats"]);
|
|
|
|
// optimistic update
|
|
queryClient.setQueryData(["chats"], (prev: any) => [
|
|
{ id: "-1", role: "user", content: data },
|
|
...prev,
|
|
]);
|
|
return { prevData };
|
|
},
|
|
onError: (_err, _data, ctx) => {
|
|
queryClient.setQueryData(["chats"], ctx?.prevData);
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
|
const msgEl = document.querySelector('[name="message"]') as
|
|
| HTMLInputElement
|
|
| undefined;
|
|
setTimeout(() => msgEl?.focus(), 100);
|
|
},
|
|
});
|
|
|
|
const onMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
|
if (isMobile) {
|
|
if (isOpen) {
|
|
setOpen(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (moveWindowStateRef.current.isMoving) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
moveWindowStateRef.current.isMoving = true;
|
|
moveWindowStateRef.current.startX =
|
|
e.clientX - moveWindowStateRef.current.x;
|
|
moveWindowStateRef.current.startY =
|
|
e.clientY - moveWindowStateRef.current.y;
|
|
};
|
|
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
const container = containerRef.current;
|
|
if (isMobile || !moveWindowStateRef.current.isMoving || !container) {
|
|
return;
|
|
}
|
|
|
|
const x = e.clientX - moveWindowStateRef.current.startX;
|
|
const y = e.clientY - moveWindowStateRef.current.startY;
|
|
moveWindowStateRef.current.x = x;
|
|
moveWindowStateRef.current.y = y;
|
|
container.style.transform = `translate(${x}px, ${y}px)`;
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
moveWindowStateRef.current.isMoving = false;
|
|
};
|
|
|
|
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
if (sendMessage.isPending) {
|
|
return;
|
|
}
|
|
|
|
const form = e.target as HTMLFormElement;
|
|
const data = new FormData(form);
|
|
|
|
const message = data.get("message") as string;
|
|
if (!message?.length) {
|
|
return;
|
|
}
|
|
|
|
sendMessage.mutate(message, {
|
|
onSuccess: () => form.reset(),
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
document.addEventListener("pointermove", onMouseMove);
|
|
document.addEventListener("pointerup", onMouseUp);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
document.removeEventListener("pointermove", onMouseMove);
|
|
document.removeEventListener("pointerup", onMouseUp);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className={cn(
|
|
"flex md:hidden absolute bottom-4 right-4 h-14 px-4 rounded-xl gap-x-2 bg-white text-slate-600 shadow-lg active:opacity-80 flex-row items-center justify-center",
|
|
isOpen && "hidden"
|
|
)}
|
|
onClick={() => setOpen(!isOpen)}
|
|
>
|
|
<span>Chat</span>
|
|
<MessageSquareQuote />
|
|
</button>
|
|
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"bg-white/20 border border-white/20 shadow-lg rounded-lg backdrop-blur-md absolute bottom-[10px] sm:bottom-1/4 left-[10px] sm:left-[10%] w-[calc(100%-20px)] sm:max-w-[320px] h-[80vh] sm:h-[300px] lg:max-w-[400px] lg:h-[350px] flex flex-col items-stretch overflow-hidden transition-transform sm:transition-none translate-y-[110%] sm:translate-y-0",
|
|
isOpen && "translate-y-0"
|
|
)}
|
|
>
|
|
<div
|
|
className="flex flex-row items-center gap-2 px-3 h-8 cursor-move"
|
|
onMouseDown={onMouseDown}
|
|
onPointerDown={onMouseDown}
|
|
>
|
|
<div className="size-3 rounded-full bg-red-500" />
|
|
<div className="size-3 rounded-full bg-yellow-500" />
|
|
<div className="size-3 rounded-full bg-green-500" />
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto flex flex-col-reverse gap-y-2 p-2">
|
|
{sendMessage.isPending && (
|
|
<Message
|
|
name="Furina"
|
|
role="model"
|
|
children={
|
|
<ThreeDots
|
|
visible={true}
|
|
height="16"
|
|
width="32"
|
|
color="#5381c7"
|
|
ariaLabel="writing.."
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{sendMessage.isError && (
|
|
<p className="text-xs text-center self-center text-black my-4 bg-white/10 backdrop-blur-md px-2 py-1 rounded-lg">
|
|
{getSendChatErrorMessage(sendMessage.error)}
|
|
</p>
|
|
)}
|
|
|
|
{messages?.map((msg: any) => {
|
|
return (
|
|
<Message
|
|
key={msg.id}
|
|
isMe={msg.role === "user"}
|
|
name={msg.role === "user" ? "Me" : "Furina"}
|
|
role={msg.role}
|
|
children={<Markdown>{msg.content}</Markdown>}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<form onSubmit={onSubmit}>
|
|
<div className="p-2 flex flex-row items-center pt-1">
|
|
<input
|
|
name="message"
|
|
className="w-full border-none rounded-full text-sm px-3 h-8 focus:outline-none"
|
|
placeholder="Write Message..."
|
|
required
|
|
disabled={sendMessage.isPending}
|
|
/>
|
|
<button
|
|
className="text-white size-8 shrink-0 hover:bg-white/40 rounded-full flex items-center justify-center -mr-1"
|
|
disabled={sendMessage.isPending}
|
|
>
|
|
<SendIcon size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
type MessageProps = {
|
|
isMe?: boolean;
|
|
role?: string;
|
|
name: string;
|
|
children?: React.ReactNode;
|
|
};
|
|
|
|
const Message = ({ isMe, role, name, children }: MessageProps) => {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex items-start gap-2 w-full max-w-[90%]",
|
|
isMe && "justify-end self-end pr-1"
|
|
)}
|
|
>
|
|
{role === "model" && (
|
|
<div className="size-8 rounded-full bg-white shrink-0 overflow-hidden">
|
|
<img src={furinaAvatar} className="w-full h-full object-cover" />
|
|
</div>
|
|
)}
|
|
|
|
<div className={cn("flex flex-col", isMe && "items-end")}>
|
|
<p className="font-medium -mt-1 text-sm text-white">{name}</p>
|
|
<div
|
|
className={cn(
|
|
"bg-white/40 backdrop-blur-md text-slate-900 rounded-xl px-2 py-1 mt-0.5 text-sm",
|
|
isMe ? "bg-white/80 rounded-tr-none" : "rounded-tl-none"
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getSendChatErrorMessage = (error: Error) => {
|
|
if (error?.message?.includes("FinishReasonSafety")) {
|
|
return "Your message probably detected with blocked words, please try again.";
|
|
}
|
|
|
|
return "An error occured. Please try again.";
|
|
};
|
|
|
|
export default ChatWindow;
|