infoalloggi-monorepo/apps/infoalloggi/src/components/chat/chat-list.tsx

440 lines
12 KiB
TypeScript
Raw Normal View History

2025-08-04 17:45:44 +02:00
"use client";
2025-08-28 18:27:07 +02:00
import { AnimatePresence, motion } from "framer-motion";
import { Pencil, Trash2 } from "lucide-react";
2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
2025-08-04 17:45:44 +02:00
} from "react";
import toast from "react-hot-toast";
import {
2025-08-28 18:27:07 +02:00
ChatBubble,
ChatBubbleAction,
ChatBubbleActionWrapper,
ChatBubbleMessage,
ChatBubbleReadStatus,
ChatBubbleTimestamp,
2025-08-04 17:45:44 +02:00
} from "~/components/chat/chat-bubble";
import { ChatMessageList } from "~/components/chat/chat-message-list";
2025-08-28 18:27:07 +02:00
import Input from "~/components/custom_ui/input";
import LoadingButton from "~/components/custom_ui/loading-button";
2025-08-04 17:45:44 +02:00
import {
2025-08-28 18:27:07 +02:00
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
2025-08-04 17:45:44 +02:00
} from "~/components/ui/alert-dialog";
2025-08-28 18:27:07 +02:00
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { getUserColor, UserAvatar } from "~/components/user_avatar";
import type { LiveChat } from "~/hooks/chatHooks";
import { cn } from "~/lib/utils";
2025-08-04 17:45:44 +02:00
import { useTranslation } from "~/providers/I18nProvider";
2025-08-28 18:27:07 +02:00
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { MessagesMessageid } from "~/schemas/public/Messages";
2025-08-04 17:45:44 +02:00
import type { UsersId } from "~/schemas/public/Users";
import type { Session } from "~/server/api/trpc";
2025-08-28 18:27:07 +02:00
import type { ChatMessage } from "~/server/services/messages.service";
import type { WhoIsTyping } from "~/server/sse";
import { api } from "~/utils/api";
2025-08-04 17:45:44 +02:00
type ChatListProps = {
2025-08-28 18:27:07 +02:00
chatId: ChatsChatid;
messages: ChatMessage[];
userData: Session;
query: LiveChat["query"];
typingStatus: WhoIsTyping[];
2025-08-04 17:45:44 +02:00
};
export const ChatList = ({
2025-08-28 18:27:07 +02:00
chatId,
messages,
userData,
query,
typingStatus,
2025-08-04 17:45:44 +02:00
}: ChatListProps) => {
2025-08-28 18:27:07 +02:00
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const handleEditOpen = (status: boolean) => {
setEditModalOpen(status);
};
const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status);
};
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({
2025-08-29 16:18:32 +02:00
onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" });
},
2025-08-28 18:27:07 +02:00
onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
},
onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
},
});
const { mutate: editMutation } = api.messagesSSE.edit.useMutation({
2025-08-29 16:18:32 +02:00
onError: () => {
toast.error("Error editing message", { id: "editMessage" });
},
2025-08-28 18:27:07 +02:00
onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" });
},
onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" });
},
});
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("message-edit") || !formData.get("message-id")) {
return;
}
editMutation({
chatId,
message: formData.get("message-edit") as string,
2025-08-29 16:18:32 +02:00
messageId: formData.get("message-id") as MessagesMessageid,
2025-08-28 18:27:07 +02:00
});
setEditModalOpen(false);
},
[editMutation],
);
const [locked, setLocked] = useState(false);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
useEffect(() => {
if (
messagesContainerRef.current &&
!locked &&
!query.isLoading &&
!query.isFetching
) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
setLocked(false);
}, [messages, typingStatus]);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
<ChatMessageList ref={messagesContainerRef}>
<AnimatePresence key="chat-messages">
{query.hasNextPage && (
<LoadingButton
aria-label="Load More Messages"
2025-08-29 16:18:32 +02:00
className="mb-3"
2025-08-28 18:27:07 +02:00
disabled={!query.hasNextPage || query.isFetchingNextPage}
2025-08-29 16:18:32 +02:00
key="load-more"
loading={query.isFetchingNextPage}
2025-08-28 18:27:07 +02:00
onClick={async () => {
setLocked(true);
await query.fetchNextPage();
}}
2025-08-29 16:18:32 +02:00
variant="secondary"
2025-08-28 18:27:07 +02:00
>
{query.isFetchingNextPage
? "Caricamento..."
: "Carica messaggi precedenti"}
</LoadingButton>
)}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
{messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id);
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isNextSameSender =
index + 1 < messages.length &&
messages[index + 1]?.sender === message.sender;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
const isPrevSameDay =
index - 1 >= 0 &&
// biome-ignore lint/style/noNonNullAssertion: <non lo so>
new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<motion.div
2025-08-29 16:18:32 +02:00
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className={cn(
"flex flex-col gap-2 pb-4",
isNextSameSender && "pb-0.5",
)}
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
2025-08-28 18:27:07 +02:00
key={message.messageid}
layout
2025-08-29 16:18:32 +02:00
style={{ originX: 0.5, originY: 0.5 }}
2025-08-28 18:27:07 +02:00
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
2025-08-29 16:18:32 +02:00
type: "spring",
2025-08-28 18:27:07 +02:00
},
2025-08-29 16:18:32 +02:00
opacity: { duration: 0.1 },
2025-08-28 18:27:07 +02:00
}}
>
{!isPrevSameDay && (
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-xs text-muted-foreground">
{new Date(message.time).toLocaleDateString("it", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})}
</span>
)}
<ChatBubble variant={variant}>
{isNextSameSender ? (
<div className="size-6"></div>
) : (
2025-08-29 16:18:32 +02:00
<UserAvatar className="size-6" userId={message.sender} />
2025-08-28 18:27:07 +02:00
)}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<div className="flex flex-col gap-1">
{!isPreviousSameSender && (
<p
className={cn(
"text-sm font-semibold",
variant === "sent" && "text-right",
)}
style={{
color: getUserColor(message.sender),
}}
>
{message.sender_nome}
</p>
)}
<ChatBubbleMessage
className={cn(isNextSameSender && "rounded-lg")}
2025-08-29 16:18:32 +02:00
isLoading={false}
2025-08-28 18:27:07 +02:00
>
{message.message}
<div className="flex items-center gap-2">
{userData.id === message.sender && (
<ChatBubbleReadStatus isread={message.isread} />
)}
{message.time && (
<ChatBubbleTimestamp
timestamp={new Date(message.time).toLocaleString(
"it",
{
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
2025-08-29 16:18:32 +02:00
month: "short",
2025-08-28 18:27:07 +02:00
},
)}
/>
)}
</div>
</ChatBubbleMessage>
</div>
<ChatBubbleActionWrapper show={userData.isAdmin}>
<ChatBubbleAction
className="size-7 hover:bg-red-50 hover:text-red-500"
icon={<Trash2 className="size-4" />}
2025-08-29 16:18:32 +02:00
key={"delete"}
2025-08-28 18:27:07 +02:00
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
/>
<ChatBubbleAction
className="size-7 hover:bg-blue-50 hover:text-blue-500"
icon={<Pencil className="size-4" />}
2025-08-29 16:18:32 +02:00
key={"edit"}
2025-08-28 18:27:07 +02:00
onClick={() => {
setMsgSelected(message);
handleEditOpen(true);
}}
/>
</ChatBubbleActionWrapper>
</ChatBubble>
</motion.div>
);
})}
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<Typers
currentTypers={typingStatus}
messageLenght={messages.length}
userId={userData.id}
/>
</AnimatePresence>
</ChatMessageList>
<EditMessageDialog
handleEditSubmit={handleEditSubmit}
2025-08-29 16:18:32 +02:00
msgEdit={msgSelected}
onOpenChange={handleEditOpen}
open={editModalOpen}
2025-08-28 18:27:07 +02:00
/>
<DeleteMessageDialog
handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId });
}}
2025-08-29 16:18:32 +02:00
msgDelete={msgSelected}
onOpenChange={handleDeleteOpen}
open={deleteModalOpen}
2025-08-28 18:27:07 +02:00
/>
</div>
);
2025-08-04 17:45:44 +02:00
};
const Typers = ({
2025-08-28 18:27:07 +02:00
currentTypers,
messageLenght,
userId,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
currentTypers: WhoIsTyping[];
messageLenght: number;
userId: UsersId;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
return (
<>
{currentTypers.map((typer, index) => {
if (typer.userId === userId) return null;
const variant = "received";
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
return (
<motion.div
2025-08-29 16:18:32 +02:00
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className="flex flex-col gap-2 py-2"
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
2025-08-28 18:27:07 +02:00
key={`typers${messageLenght}${index}`}
layout
2025-08-29 16:18:32 +02:00
style={{ originX: 0.5, originY: 0.5 }}
2025-08-28 18:27:07 +02:00
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
2025-08-29 16:18:32 +02:00
type: "spring",
2025-08-28 18:27:07 +02:00
},
2025-08-29 16:18:32 +02:00
opacity: { duration: 0.1 },
2025-08-28 18:27:07 +02:00
}}
>
<ChatBubble variant={variant}>
2025-08-29 16:18:32 +02:00
<UserAvatar className="size-6" userId={typer.userId} />
2025-08-04 17:45:44 +02:00
2025-08-28 18:27:07 +02:00
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble>
</motion.div>
);
})}
</>
);
2025-08-04 17:45:44 +02:00
};
const EditMessageDialog = ({
2025-08-28 18:27:07 +02:00
open,
onOpenChange,
msgEdit,
handleEditSubmit,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
open: boolean;
onOpenChange: (status: boolean) => void;
msgEdit: ChatMessage | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
return (
2025-08-29 16:18:32 +02:00
<Dialog onOpenChange={onOpenChange} open={open}>
2025-08-28 18:27:07 +02:00
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{msgEdit && (
2025-08-29 16:18:32 +02:00
<form method="post" onSubmit={handleEditSubmit}>
2025-08-28 18:27:07 +02:00
<div className="flex items-center gap-3">
2025-08-29 16:18:32 +02:00
<label className="sr-only" htmlFor="id">
2025-08-28 18:27:07 +02:00
ID
</label>
<Input
className="hidden"
2025-08-29 16:18:32 +02:00
id="id"
2025-08-28 18:27:07 +02:00
name="message-id"
readOnly
2025-08-29 16:18:32 +02:00
type="text"
value={msgEdit.messageid}
2025-08-28 18:27:07 +02:00
/>
2025-08-29 16:18:32 +02:00
<label className="sr-only" htmlFor="message-edit">
2025-08-28 18:27:07 +02:00
Modifica messaggio
</label>
<Input
className="mt-0"
defaultValue={msgEdit.message || undefined}
2025-08-29 16:18:32 +02:00
id="message-edit"
name="message-edit"
placeholder="Modifica messaggio"
type="text"
2025-08-28 18:27:07 +02:00
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
2025-08-04 17:45:44 +02:00
};
const DeleteMessageDialog = ({
2025-08-28 18:27:07 +02:00
open,
onOpenChange,
msgDelete,
handleDeleteSubmit,
2025-08-04 17:45:44 +02:00
}: {
2025-08-28 18:27:07 +02:00
open: boolean;
onOpenChange: (status: boolean) => void;
msgDelete: ChatMessage | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
2025-08-04 17:45:44 +02:00
}) => {
2025-08-28 18:27:07 +02:00
const { t } = useTranslation();
return (
<>
{msgDelete && (
2025-08-29 16:18:32 +02:00
<AlertDialog onOpenChange={onOpenChange} open={open}>
2025-08-28 18:27:07 +02:00
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Message"
onClick={() => handleDeleteSubmit(msgDelete.messageid)}
>
{t.procedi}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
2025-08-04 17:45:44 +02:00
};
const getMessageVariant = (messageName: string, selectedUserName: string) =>
2025-08-28 18:27:07 +02:00
messageName !== selectedUserName ? "received" : "sent";