infoalloggi-monorepo/apps/infoalloggi/src/components/chat/chat-list.tsx
2026-04-28 14:59:05 +02:00

693 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { format, isSameDay } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { Copy, Forward, Pencil, Reply, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import toast from "react-hot-toast";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import {
ChatBubble,
ChatBubbleMessage,
ChatBubbleReadStatus,
} from "~/components/chat/chat-bubble";
import LoadingButton from "~/components/custom_ui/loading-button";
import { LoadingPage } from "~/components/loading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import Input from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { getUserColorV2, UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { UsersId } from "~/schemas/public/Users";
import type { Message, TypingData } from "~/server/api/routers/chat_sse";
import { api } from "~/utils/api";
export const ChatList = () => {
const {
chatId,
session,
liveChat,
inputRef,
setReplyTrg,
messagesContainerRef,
} = useChatContext();
const { messages, query, typingStatus } = liveChat;
const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<Message | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [inoltraModalOpen, setInoltraModalOpen] = useState(false);
const handleEditOpen = (status: boolean) => {
setEditModalOpen(status);
};
const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status);
};
const { mutate: deleteMutation } = api.chatSSE.deleteMessage.useMutation({
onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" });
},
onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
},
onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
},
});
const { mutate: editMutation } = api.chatSSE.editMessage.useMutation({
onError: () => {
toast.error("Error editing message", { id: "editMessage" });
},
onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" });
},
onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" });
},
});
const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const raw_messageId = formData.get("message-id");
const raw_message = formData.get("message-edit");
if (!raw_message || !raw_messageId) {
return;
}
const messageId = raw_messageId.toString() as MessagesMessageid;
const message = raw_message.toString();
editMutation({
chatId,
message,
messageId,
});
setEditModalOpen(false);
},
[editMutation],
);
const [locked, setLocked] = useState(false);
const focusInputOnCloseRef = useRef(false);
useEffect(() => {
if (
messagesContainerRef.current &&
!locked &&
!query.isLoading &&
!query.isFetching
) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
setLocked(false);
}, [messages, typingStatus]);
const handleFetchPrevious = useCallback(async () => {
if (query.hasNextPage && !query.isFetchingNextPage) {
setLocked(true);
await query.fetchNextPage();
}
}, [query]);
const utils = api.useUtils();
const handleScrollTo = (messageId: MessagesMessageid): boolean => {
const el = document.getElementById(messageId);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
el.classList.add("bg-primary/50");
setTimeout(() => {
el.classList.remove("bg-primary/50");
}, 2000);
return true;
}
console.warn("Element not found for messageId:", messageId);
return false;
};
const handleJumpToMessage = async (messageId: MessagesMessageid) => {
const scroll = handleScrollTo(messageId);
if (scroll) return;
const context = await utils.chatSSE.getChat.fetch({
chatId,
targetMsgId: messageId,
});
utils.chatSSE.getChat.setInfiniteData({ chatId }, () => {
return {
pages: [context],
pageParams: [null] as (MessagesMessageid | null)[],
};
});
setTimeout(() => {
handleScrollTo(messageId);
}, 100);
console.log(query.data);
};
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
<div
className={cn("flex h-full w-full flex-col overflow-y-auto p-1")}
ref={messagesContainerRef}
>
<AnimatePresence key="chat-messages">
{query.hasNextPage && (
<LoadingButton
aria-label="Load More Messages"
className="mb-3"
disabled={!query.hasNextPage || query.isFetchingNextPage}
key="load-more"
loading={query.isFetchingNextPage}
onClick={handleFetchPrevious}
variant="secondary"
>
{query.isFetchingNextPage
? "Caricamento..."
: "Carica messaggi precedenti"}
</LoadingButton>
)}
{messages.map((message, index) => {
const isNextSameSender =
index + 1 < messages.length &&
messages[index + 1]?.sender === message.sender;
const isPrevSameDay = (() => {
const prevMsg = messages[index - 1];
if (!prevMsg) return false;
return isSameDay(new Date(prevMsg.time), new Date(message.time));
})();
const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
const isSender = message.sender === session.id;
const userColor = getUserColorV2(message.color_str);
const reply_userColor = message.reply_source
? getUserColorV2(message.reply_source.color_str)
: undefined;
return (
<div
className={cn(
"flex flex-col gap-0.5 pb-4",
isNextSameSender && "pb-0.5",
)}
key={message.messageid}
>
{!isPrevSameDay && (
<span className="mx-auto mt-1 rounded-md bg-primary px-2 py-1 text-center text-secondary text-xs">
{new Date(message.time).toLocaleDateString("it", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})}
</span>
)}
{!isPreviousSameSender && (
<p
className={cn(
"px-1.5 font-semibold text-sm",
isSender && "text-right",
)}
style={{
color: userColor,
}}
>
{message.sender_nome}
</p>
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className={cn(
"flex flex-col gap-2 rounded-md px-1",
"data-[state=open]:bg-muted",
)}
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
id={message.messageid}
initial={{ opacity: 0, scale: 1, x: 0, y: 25 }}
layout
style={{ originX: 0.25, originY: 0.25 }}
transition={{
layout: {
bounce: 0.2,
duration: 0.2,
type: "spring",
},
opacity: { duration: 0.1 },
}}
>
<ChatBubble variant={isSender ? "sent" : "received"}>
<ChatBubbleMessage
className={cn(
"flex flex-col items-end justify-end gap-x-2 gap-y-1",
isNextSameSender && "rounded-lg",
"",
)}
variant={isSender ? "sent" : "received"}
>
{message.reply_source && (
<button
className="group block w-full cursor-pointer text-left"
onClick={async () => {
if (!message.reply_source) return;
await handleJumpToMessage(
message.reply_source.messageid,
);
}}
type="button"
>
<div
className="mt-1 w-full rounded-md border-l-4 bg-secondary px-2 py-0.5 text-foreground group-hover:bg-secondary/90"
style={{ borderColor: reply_userColor }}
>
<div className="flex items-baseline gap-1">
<Reply className="size-3.5 text-primary" />
<span
className="font-semibold text-xs"
style={{
color: reply_userColor,
}}
>
{message.reply_source.sender === session.id
? "Tu"
: message.reply_source.nome}
</span>
</div>
<span className="line-clamp-2 block break-all text-xs italic">
{message.reply_source.message}
</span>
</div>
</button>
)}
{message.has_attachments && (
<ChatAttachments messageId={message.messageid} />
)}
<span className="flex shrink break-all">
{message.message}
</span>
<div
className={cn(
"flex items-center gap-1",
isSender && "justify-end",
)}
>
{message.time && (
<div
className={cn(
"text-[0.70rem]",
isSender
? "text-muted"
: "text-muted-foreground",
)}
>
{format(new Date(message.time), "HH:mm")}
</div>
)}
{isSender && (
<ChatBubbleReadStatus isread={message.isread} />
)}
</div>
</ChatBubbleMessage>
</ChatBubble>
</motion.div>
</ContextMenuTrigger>
<ContextMenuContent
onCloseAutoFocus={(e) => {
if (focusInputOnCloseRef.current) {
e.preventDefault();
inputRef.current?.textArea.focus();
focusInputOnCloseRef.current = false;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
}
}}
>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
navigator.clipboard.writeText(message.message);
toast.success("Messaggio copiato negli appunti", {
id: "copyMessage",
});
}}
>
<Copy />
Copia
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setReplyTrg(message);
focusInputOnCloseRef.current = true;
}}
>
<Reply />
Rispondi
</ContextMenuItem>
{session.isAdmin && (
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setMsgSelected(message);
setInoltraModalOpen(true);
}}
>
<Forward />
Inoltra
</ContextMenuItem>
)}
{(isSender || session.isAdmin) && (
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setMsgSelected(message);
handleEditOpen(true);
}}
>
<Pencil />
Modifica
</ContextMenuItem>
)}
{session.isAdmin && (
<>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
>
<Trash2 />
Cancella
</ContextMenuItem>{" "}
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
);
})}
<Typers
currentTypers={typingStatus}
messageLenght={messages.length}
userId={session.id}
/>
</AnimatePresence>
</div>
{session.isAdmin && (
<InoltraDialog
isOpen={inoltraModalOpen}
msgSelected={msgSelected}
openChange={(status) => setInoltraModalOpen(status)}
/>
)}
<EditMessageDialog
handleEditSubmit={handleEditSubmit}
msgEdit={msgSelected}
onOpenChange={handleEditOpen}
open={editModalOpen}
/>
<DeleteMessageDialog
handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId });
}}
msgDelete={msgSelected}
onOpenChange={handleDeleteOpen}
open={deleteModalOpen}
/>
</div>
);
};
const Typers = ({
currentTypers,
messageLenght,
userId,
}: {
currentTypers: TypingData;
messageLenght: number;
userId: UsersId;
}) => {
return (
<>
{currentTypers.map((typer) => {
if (typer.userId === userId) return null;
const variant = "received";
return (
<motion.div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className="flex flex-col gap-2 px-1 py-2"
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
key={`typers${messageLenght}${typer.userId}`}
layout
style={{ originX: 0.5, originY: 0.5 }}
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
type: "spring",
},
opacity: { duration: 0.1 },
}}
>
<ChatBubble variant={variant}>
<ChatBubbleMessage className="text-muted-foreground text-xs">
{typer.username} sta scrivendo...
</ChatBubbleMessage>
</ChatBubble>
</motion.div>
);
})}
</>
);
};
const EditMessageDialog = ({
open,
onOpenChange,
msgEdit,
handleEditSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgEdit: Message | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => {
const { t } = useTranslation();
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{msgEdit && (
<form method="post" onSubmit={handleEditSubmit}>
<div className="flex items-center gap-3">
<label className="sr-only" htmlFor="id">
ID
</label>
<Input
className="hidden"
id="id"
name="message-id"
readOnly
type="text"
value={msgEdit.messageid}
/>
<label className="sr-only" htmlFor="message-edit">
Modifica messaggio
</label>
<Textarea
className="mt-0 min-h-64"
defaultValue={msgEdit.message || undefined}
id="message-edit"
name="message-edit"
placeholder="Modifica messaggio"
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
};
const DeleteMessageDialog = ({
open,
onOpenChange,
msgDelete,
handleDeleteSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgDelete: Message | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
}) => {
const { t } = useTranslation();
return (
<>
{msgDelete && (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<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>
)}
</>
);
};
const InoltraDialog = ({
isOpen,
openChange,
msgSelected,
}: {
isOpen: boolean;
openChange: (status: boolean) => void;
msgSelected: Message | null;
}) => {
const router = useRouter();
const { chatId, session } = useChatContext();
const { data: activeChats, isLoading } = api.chat.getActiveChats.useQuery(
undefined,
{
enabled: isOpen,
},
);
const { mutate } = api.chatSSE.sendMessage.useMutation({
onError: () => {
toast.error("Errore nell'inoltro del messaggio", {
id: "forwardMessage",
});
},
onMutate: () => {
toast("Inoltrando messaggio...", { icon: "📨", id: "forwardMessage" });
},
onSuccess: () => {
toast("Messaggio inoltrato", { icon: "📨", id: "forwardMessage" });
},
});
return (
<CommandDialog onOpenChange={openChange} open={isOpen}>
<CommandInput
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
placeholder="Digita il nome utente..."
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
{isLoading && <LoadingPage />}
<CommandGroup className="pb-2" heading={"Suggerimenti"}>
{activeChats
?.filter((chat) => chat.chatid !== chatId)
.map((chat) => (
<CommandItem
className="cursor-pointer rounded-md"
key={chat.id}
onSelect={() => {
if (!msgSelected) return;
mutate({
chatId: chat.chatid,
message: msgSelected.message,
sender: session.id,
is_forwarded: true,
});
router.push(`/area-riservata/admin/chats/${chat.chatid}`);
}}
>
<span className="flex items-center gap-2">
<UserAvatar
className="size-8"
userId={chat.id}
username={chat.username}
/>
<span>
<span className="block font-medium">{chat.username}</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};