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

687 lines
18 KiB
TypeScript
Raw Normal View History

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 {
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 rounded-md bg-muted px-2 py-1 text-center text-muted-foreground 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-wrap 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 }}
>
<span
className="font-semibold text-xs"
style={{
color: reply_userColor,
}}
>
{message.reply_source.sender === session.id
? "Tu"
: message.reply_source.nome}
</span>
<span className="line-clamp-2 block break-all text-xs italic">
{message.reply_source.message}
</span>
</div>
</button>
)}
<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>
);
};