Files
2026-07-16 20:48:43 -03:00

723 lines
32 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, Suspense } from "react";
import Secure from "../components/Secure/Secure";
import Alert from "@components/Alert/Alert";
import Header from "@components/Header/Header";
import { SystemNotificationsProvider } from "@components/SystemNotificationsProvider/SystemNotificationsProvider";
import UserMenu from "@components/UserMenu/UserMenu";
import style from "./style.module.css";
import useWindowSize from "../hooks/WindowSize";
import classNames from "classnames";
import Textbox from "../components/Textbox/Textbox";
import {
FindConversationItem,
IMessage,
ISendMessageResult,
MESSAGE_CONVERSATION_TYPES,
SendMessageParams,
} from "@core/Models/Messages.model";
import {
markConversationAsRead,
messagesFind,
messagesFindContacts,
messagesFindConversations,
sendMessage,
} from "./Messages.service";
import { useSessionStore } from "@core/Store/Sesion.Store";
import Avatar from "../components/Avatar/Avatar";
import SendIcon from "@mui/icons-material/Send";
import BackIcon from "@mui/icons-material/ArrowBack";
import { motion } from "motion/react";
import { IconButton } from "@mui/material";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useAlert } from "@core/Store/Alert.Store";
import { getChatClientSocket } from "./Messages.socket";
import { Socket } from "socket.io-client";
import dayjs from "dayjs";
import "dayjs/locale/es";
import { blurElementsOnLoad } from "@core/helpers/blur";
import HeaderConfProvider from "../components/HeaderConfProvider/HeaderConfProvider";
import SuspenseLoading from "../components/SuspendeLoading/SuspenseLoading";
dayjs.locale("es");
/**
* TODO:
* En la columna de conversaciones, vamos a poner en la parte superior una caja de texto
* que permita buscar organizaciones, y en el caso de las organizaciones que el usuario es cliente
* se tienen que mostrar los colaboradores de esa organizacion.
* Si el usuario es colaborador de la organizacion, se tienen que mostrar los clientes de esa organizacion
* y los otros colaboradores de la organizacion.
*/
enum VIEW_FOCUS {
CONVERSATIONS = "CONVERSATIONS",
MESSAGES = "MESSAGES",
}
enum MESSENGER_VIEWS {
CONVERSATIONS = "CONVERSATIONS",
MESSAGES = "MESSAGES",
BOTH = "BOTH",
}
type ViewClasses =
| "MessengerContainer"
| "MessengerBody"
| "ConversationsContainer"
| "ConversationsHeader"
| "ConversationsBody"
| "MessagesContainer"
| "MessagesHeader"
| "MessagesBody";
const classesMap = {
CONVERSATIONS: {
MessengerContainer: style.MessengerContainer_CONVERSATIONS,
MessengerBody: style.MessengerBody_CONVERSATIONS,
ConversationsContainer: style.ConversationsContainer_CONVERSATIONS,
ConversationsHeader: style.ConversationsHeader_CONVERSATIONS,
ConversationsBody: style.ConversationsBody_CONVERSATIONS,
MessagesContainer: style.MessagesContainer_CONVERSATIONS,
MessagesHeader: style.MessagesHeader_CONVERSATIONS,
MessagesBody: style.MessagesBody_CONVERSATIONS,
},
MESSAGES: {
MessengerContainer: style.MessengerContainer_MESSAGES,
MessengerBody: style.MessengerBody_MESSAGES,
ConversationsContainer: style.ConversationsContainer_MESSAGES,
ConversationsHeader: style.ConversationsHeader_MESSAGES,
ConversationsBody: style.ConversationsBody_MESSAGES,
MessagesContainer: style.MessagesContainer_MESSAGES,
MessagesHeader: style.MessagesHeader_MESSAGES,
MessagesBody: style.MessagesBody_MESSAGES,
},
BOTH: {
MessengerContainer: style.MessengerContainer_BOTH,
MessengerBody: style.MessengerBody_BOTH,
ConversationsContainer: style.ConversationsContainer_BOTH,
ConversationsHeader: style.ConversationsHeader_BOTH,
ConversationsBody: style.ConversationsBody_BOTH,
MessagesContainer: style.MessagesContainer_BOTH,
MessagesHeader: style.MessagesHeader_BOTH,
MessagesBody: style.MessagesBody_BOTH,
},
};
interface FindConversationParams {
selectConversationId?: string;
page?: number;
}
export default function Messages() {
const SessionInfo = useSessionStore();
const alert = useAlert();
const eventHandler = useEventHandlerStore();
const [width, height] = useWindowSize();
const [view, setView] = useState<MESSENGER_VIEWS>(MESSENGER_VIEWS.BOTH);
const [viewFocus, setViewFocus] = useState<VIEW_FOCUS>(VIEW_FOCUS.CONVERSATIONS);
const [conversations, setConversations] = useState<FindConversationItem[]>([]);
const [messages, setMessages] = useState<IMessage[]>([]);
const [selectedConversation, setSelectedConversation] = useState<
FindConversationItem | undefined
>(undefined);
const [filter, setFilter] = useState<string>("");
const [chatText, setChatText] = useState<string>("");
const chatBodyRef = useRef<HTMLDivElement>(null);
const [chatSocket, setChatSocket] = useState<Socket | undefined>(undefined);
const [newMessageReceived, setNewMessageReceived] = useState<ISendMessageResult | null>(null);
const [loadingChatNextPage, setLoadingChatNextPage] = useState<boolean>(false);
const [chatPage, setChatPage] = useState<number>(1);
const getClassName = (name: ViewClasses) => {
return classNames(style[name], classesMap[view][name]);
};
const getMessageClassName = (message: IMessage) => {
if (message.userId === SessionInfo.userId) {
return classNames(style.MessageContainerSender);
} else {
return classNames(style.MessageContainerReceiver);
}
};
const viewManager = () => {
if (width < 800) {
if (viewFocus === VIEW_FOCUS.CONVERSATIONS) {
setView(MESSENGER_VIEWS.CONVERSATIONS);
} else if (viewFocus === VIEW_FOCUS.MESSAGES) {
setView(MESSENGER_VIEWS.MESSAGES);
}
} else {
setView(MESSENGER_VIEWS.BOTH);
}
};
const findContacts = (query: string) => {
messagesFindContacts({
page: 1,
query: query,
sessionUser: SessionInfo.userId,
limit: 20,
})
.then((result) => {
setConversations(result.data);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const findConversations = ({ page = 1, selectConversationId = "" }: FindConversationParams) => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
messagesFindConversations({
page: page,
sessionUser: SessionInfo.userId,
limit: 20,
})
.then((result) => {
setConversations(result.data);
if (selectConversationId != "") {
setSelectedConversation(
result.data.find((item) => item.conversation.id === selectConversationId)
);
}
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const loadConversationChat = (markAsRead: boolean = false) => {
if (!selectedConversation) {
return;
}
if (!selectedConversation.conversation.id) {
setMessages([]);
return;
}
messagesFind({
conversationId: selectedConversation.conversation.id,
sessionUser: SessionInfo.userId,
page: 1,
limit: 20,
})
.then((result) => {
const messages = result.data;
const orderedMessages = messages.sort((a, b) => {
return new Date(a.messageDate).getTime() - new Date(b.messageDate).getTime();
});
setMessages(orderedMessages);
setChatPage(1);
if (markAsRead) {
setTimeout(() => {
markCurrentConversationAsReadHandler();
markConversationsAsReadHandler();
}, 2000);
}
})
.finally(() => {
chatScrollToEnd();
});
};
const loadConversationChatNextPage = () => {
if (!selectedConversation) {
return;
}
if (!selectedConversation.conversation.id) {
setMessages([]);
return;
}
if (loadingChatNextPage) {
return;
}
setLoadingChatNextPage(true);
const chatBody = chatBodyRef.current;
const previousScrollHeight = chatBody?.scrollHeight || 0;
const previousScrollTop = chatBody?.scrollTop || 0;
messagesFind({
conversationId: selectedConversation.conversation.id,
sessionUser: SessionInfo.userId,
page: chatPage + 1,
limit: 20,
})
.then((result) => {
if (result.page > chatPage && result.data.length > 0) {
setChatPage(result.page);
const messagesResult = [...messages, ...result.data];
const orderedMessages = messagesResult.sort((a, b) => {
return (
new Date(a.messageDate).getTime() - new Date(b.messageDate).getTime()
);
});
setMessages(orderedMessages);
//Lleva dobre requestAnimationFrame porque sino no se entera del nuevo tamanio del div.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const chatBody = chatBodyRef.current;
if (chatBody) {
const newScrollHeight = chatBody.scrollHeight;
const delta = newScrollHeight - previousScrollHeight;
chatBody.scrollTop = previousScrollTop + delta;
}
});
});
}
})
.finally(() => {
setLoadingChatNextPage(false);
});
};
const send = () => {
if (!selectedConversation) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
const conversationType = selectedConversation.conversation.type;
const sendMessageData: SendMessageParams = {
conversationId: selectedConversation.conversation.id
? selectedConversation.conversation.id
: undefined,
type: conversationType,
userIdFrom: SessionInfo.userId,
userIdTo:
conversationType == MESSAGE_CONVERSATION_TYPES.USER_TO_USER
? selectedConversation.conversation.userIdTo
: undefined,
companyId:
conversationType == MESSAGE_CONVERSATION_TYPES.USER_TO_ORG
? selectedConversation.conversation.companyId
: undefined,
message: chatText,
sessionUser: SessionInfo.userId,
};
sendMessage(sendMessageData)
.then((result) => {
if (!sendMessageData.conversationId) {
findConversations({
page: 1,
selectConversationId: result.conversation.id,
});
return;
}
loadConversationChat();
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
chatScrollToEnd();
setChatText("");
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
const conversationSelectedHandler = (item: FindConversationItem) => {
if (view != MESSENGER_VIEWS.BOTH) {
setView(MESSENGER_VIEWS.MESSAGES);
setViewFocus(VIEW_FOCUS.MESSAGES);
}
setSelectedConversation(item);
setChatText("");
setFilter("");
};
const chatScrollToEnd = () => {
requestAnimationFrame(() => {
const chatBody = chatBodyRef.current;
if (chatBody) {
chatBody.scrollTop = chatBody.scrollHeight;
}
});
};
const chatScrollHandler = () => {
const chatBody = chatBodyRef.current;
if (chatBody && chatBody.scrollTop <= 50) {
loadConversationChatNextPage();
}
};
const updateOtherConversationsReadedCounter = (data: ISendMessageResult) => {
const conversation = conversations.find(
(c) => c.conversation.id === data.message.conversationId
);
if (conversation) {
const newMessagesCount = conversation.conversation.newMessages
? conversation.conversation.newMessages + 1
: 1;
conversation.conversation.newMessages = newMessagesCount;
const indexConversation = conversations.indexOf(conversation);
const conversationsUpdated = [...conversations];
conversationsUpdated[indexConversation] = conversation;
setConversations(conversationsUpdated);
}
};
const newMessageReceivedHandler = (data: ISendMessageResult) => {
if (!selectedConversation) {
updateOtherConversationsReadedCounter(data);
return;
}
if (selectedConversation.conversation.id != data.message.conversationId) {
updateOtherConversationsReadedCounter(data);
return;
}
const updatedMessages = [...messages, ...[data.message]];
setMessages(updatedMessages);
chatScrollToEnd();
};
const markConversationsAsReadHandler = () => {
if (!selectedConversation || !selectedConversation.conversation.id) {
return;
}
if (!SessionInfo.userId) {
return;
}
markConversationAsRead({
conversationId: selectedConversation.conversation.id,
sessionUser: SessionInfo.userId,
});
};
const markCurrentConversationAsReadHandler = () => {
if (!selectedConversation || !selectedConversation.conversation.id) {
return;
}
const conversation = conversations.find(
(c) => c.conversation.id === selectedConversation.conversation.id
);
if (conversation) {
conversation.conversation.newMessages = 0;
const indexConversation = conversations.indexOf(conversation);
const conversationsUpdated = [...conversations];
conversationsUpdated[indexConversation] = conversation;
setConversations(conversationsUpdated);
}
};
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
setSelectedConversation(undefined);
blurElementsOnLoad();
}, []);
useEffect(() => {
if (!SessionInfo.userId) {
return;
}
if (filter != "") {
findContacts(filter);
return;
}
findConversations({ page: 1 });
}, [SessionInfo.userId, filter]);
useEffect(() => {
if (!SessionInfo.userId) {
if (chatSocket) {
chatSocket.off("new_message");
chatSocket.disconnect();
}
return;
}
loadConversationChat(true);
if (chatSocket) {
return;
}
const socket = getChatClientSocket(SessionInfo.userId);
setChatSocket(socket);
}, [SessionInfo.userId, selectedConversation]);
useEffect(() => {
if (!chatSocket) {
return;
}
chatSocket.on("new_message", (message: ISendMessageResult) => {
setNewMessageReceived(message);
});
return () => {
chatSocket.off("new_message");
};
}, [chatSocket]);
useEffect(() => {
if (!newMessageReceived) {
return;
}
newMessageReceivedHandler(newMessageReceived);
}, [newMessageReceived]);
useEffect(() => {
viewManager();
}, [width, height]);
return (
<Suspense fallback={<SuspenseLoading />}>
<div>
<Header />
<HeaderConfProvider />
<SystemNotificationsProvider />
<Secure>
<div className={getClassName("MessengerContainer")}>
<div className={getClassName("MessengerBody")}>
<div className={getClassName("ConversationsContainer")}>
<div className={getClassName("ConversationsHeader")}>
<Textbox
type="text"
value={filter}
width="100%"
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={getClassName("ConversationsBody")}>
{conversations.length === 0 && (
<div className={style.NoContacts}>
No hay conversaciones activas. Puede buscar contactos en
el cuadro de busqueda superior.
</div>
)}
{conversations.map((item, index) => {
return (
<motion.div
key={"conversation." + index}
className="w-64 h-40 rounded-2xl cursor-pointer"
initial={{ backgroundColor: "var(--white)" }}
whileHover={{
backgroundColor: "var(--gray-lighter)",
color: "var(--black)",
}} // Azul claro en hover
whileTap={{
backgroundColor: "var(--wine-dark)",
color: "white",
}} // Azul más fuerte en tap
transition={{ duration: 0.2 }}
>
<div
className={style.ConversationItem}
onClick={() => {
conversationSelectedHandler(item);
}}
>
<div>
<Avatar
src={item.image}
alt={item.name}
size="medium"
border="none"
/>
</div>
<div style={{ width: "100%" }}>
<div
className={style.ConversationItemTitle}
>
{item.name}
</div>
<div
className={
style.ConversationItemDescription
}
>
{item.description}
</div>
</div>
{item.conversation.newMessages > 0 && (
<div
className={
style.ConversationItemNewMessages
}
>
{item.conversation.newMessages > 0
? item.conversation.newMessages
: "-1"}
</div>
)}
</div>
</motion.div>
);
})}
</div>
</div>
<div className={getClassName("MessagesContainer")}>
{!selectedConversation && (
<div className={style.NoConversation}>
Seleccione un conversación
</div>
)}
{selectedConversation && (
<>
<div className={getClassName("MessagesHeader")}>
{selectedConversation && (
<div className={style.MessageHeaderLeft}>
{view === MESSENGER_VIEWS.MESSAGES && (
<div
style={{
display: "flex",
alignItems: "center",
}}
>
<IconButton
onClick={() => {
setView(
MESSENGER_VIEWS.CONVERSATIONS
);
setViewFocus(
VIEW_FOCUS.CONVERSATIONS
);
}}
>
<BackIcon />
</IconButton>
</div>
)}
<div>
<Avatar
src={selectedConversation.image}
alt={selectedConversation.name}
size="small"
border="none"
/>
</div>
<div>
<div
className={style.MessageHeaderLeftTitle}
>
{selectedConversation.name}
</div>
<div
className={
style.MessageHeaderLeftDescription
}
>
{selectedConversation.description}
</div>
</div>
</div>
)}
</div>
<div
ref={chatBodyRef}
className={getClassName("MessagesBody")}
onScroll={chatScrollHandler}
>
<div className={style.MessageChatContainer}>
{messages.map((item, index) => {
const showDate =
index === 0 ||
!dayjs(item.messageDate)
.startOf("day")
.isSame(
dayjs(
messages[
index - 1
].messageDate.toString()
).startOf("day")
);
return (
<div key={"message." + index}>
{showDate && (
<div
className={
style.MessageChatDateContainer
}
>
<div
className={
style.MessageChatDate
}
>
{dayjs(
item.messageDate
).format(
"dddd DD [de] MMMM, YYYY"
)}
</div>
</div>
)}
<div
className={getMessageClassName(
item
)}
>
<div
key={"message." + index}
className={
style.MessageChatItem
}
>
<div>{item.message}</div>
<div>
{dayjs(
item.messageDate
).format("HH:mm")}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
<div>
<div className={style.MessageBottomBar}>
<Textbox
type="text"
value={chatText}
width="100%"
onChange={(e) => {
setChatText(e.target.value);
}}
onKeyUp={(e) => {
if (e.key === "Enter") {
send();
}
}}
/>
<IconButton color="success" onClick={send}>
<SendIcon />
</IconButton>
</div>
</div>
</>
)}
</div>
</div>
</div>
</Secure>
<UserMenu />
<Alert />
</div>
</Suspense>
);
}