omnichannel-frontend/src/modules/chat/hooks/useChat.js

768 lines
25 KiB
JavaScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react';
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
import { getCurrentUser } from '../../auth/services/sessionService';
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
function buildInitialMessages() {
return chatContacts.reduce((acc, contact) => {
acc[contact.id] = contact.messages;
return acc;
}, {});
}
function getLastMessageFromMe(messages = []) {
const lastMessage = [...messages].reverse().find(isDisplayableMessage);
if (!lastMessage) return false;
return lastMessage.sender === 'agent' || lastMessage.fromMe === true;
}
function getSerializedId(value) {
if (!value) return '';
if (typeof value === 'string') return value;
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getContactName(chat) {
const serializedId = getSerializedId(chat.id);
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
}
function getPreviewFromMessage(message) {
if (message?.body) return message.body;
if (message?.text) return message.text;
if (message?.hasMedia || message?.media) return '[Midia]';
return '';
}
function normalizeChat(chat) {
const id = getSerializedId(chat.id);
const assignment = chat.assignment || null;
2026-05-19 16:39:01 -03:00
const lastSeenTimestamp = chat.timestamp || null;
const hasLastMessageFromMe = typeof chat.lastMessageFromMe === 'boolean';
return {
id,
name: getContactName(chat),
channel: 'WhatsApp',
2026-05-19 16:39:01 -03:00
status: lastSeenTimestamp ? 'away' : 'offline',
area: assignment?.area_nome || (assignment?.area_id ? String(assignment.area_id) : 'Sem fila'),
areaId: assignment?.area_id || null,
lastSeen: lastSeenTimestamp ? `Última atividade as ${formatTime(lastSeenTimestamp)}` : 'Sem atividade recente',
preview: chat.preview || chat.lastMessage?.body || '',
time: formatTime(chat.timestamp) || 'Agora',
unread: chat.unreadCount || 0,
lastMessageFromMe: hasLastMessageFromMe ? chat.lastMessageFromMe : Boolean(chat.lastMessage?.fromMe),
contactProfile: chat.contactProfile || null,
assignment,
};
}
function normalizeMessage(message) {
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
return {
id,
chatId: message.from || message.to || message.chatId,
sender,
text: message.body ?? message.text ?? '',
timestamp: message.timestamp,
hasMedia: Boolean(message.hasMedia || message.media),
media: message.media || null,
mediaLoading: false,
mediaError: null,
};
}
2026-05-19 16:39:01 -03:00
function isDisplayableMessage(message) {
const text = String(message?.text ?? message?.body ?? '').trim();
return Boolean(text || message?.hasMedia || message?.media);
}
function getComparableMessageTime(message) {
if (message.timestamp) return Number(message.timestamp);
if (typeof message.id === 'string' && message.id.startsWith('temp-')) {
return Math.floor(Number(message.id.replace('temp-', '')) / 1000);
}
return 0;
}
function stripSenderHeader(text) {
return String(text || '')
.replace(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i, '')
.trim();
}
function areLikelySameMessage(currentMessage, nextMessage) {
if (!currentMessage || !nextMessage) return false;
if (currentMessage.id && nextMessage.id && currentMessage.id === nextMessage.id) return true;
if (currentMessage.chatId && nextMessage.chatId && currentMessage.chatId !== nextMessage.chatId) return false;
if (currentMessage.sender !== nextMessage.sender) return false;
if (stripSenderHeader(currentMessage.text) !== stripSenderHeader(nextMessage.text)) return false;
if (Boolean(currentMessage.hasMedia) !== Boolean(nextMessage.hasMedia)) return false;
const currentTime = getComparableMessageTime(currentMessage);
const nextTime = getComparableMessageTime(nextMessage);
if (!currentTime || !nextTime) return false;
return Math.abs(currentTime - nextTime) <= 10;
}
function mergeMessageList(currentMessages, nextMessage) {
const exactIndex = currentMessages.findIndex((message) => message.id === nextMessage.id);
if (exactIndex >= 0) {
return currentMessages.map((message, index) => (index === exactIndex ? { ...message, ...nextMessage } : message));
}
const likelyIndex = currentMessages.findIndex((message) => areLikelySameMessage(message, nextMessage));
if (likelyIndex >= 0) {
return currentMessages.map((message, index) => (index === likelyIndex ? { ...message, ...nextMessage } : message));
}
return [...currentMessages, nextMessage];
}
function dedupeMessages(messages) {
return messages.reduce((acc, message) => mergeMessageList(acc, message), []);
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function buildFallbackContacts() {
return chatContacts.map((contact) => ({
...contact,
assignment: null,
areaId: null,
lastMessageFromMe: getLastMessageFromMe(contact.messages),
}));
}
function normalizeComparableContact(contact) {
return {
id: contact.id,
name: contact.name,
preview: contact.preview,
time: contact.time,
unread: contact.unread,
area: contact.area,
areaId: contact.areaId,
lastSeen: contact.lastSeen,
lastMessageFromMe: contact.lastMessageFromMe,
assignmentStatus: contact.assignment?.status || null,
assignmentUserId: contact.assignment?.user_id || null,
assignmentAreaId: contact.assignment?.area_id || null,
transferNote: contact.assignment?.transfer_note || null,
awaitingCustomerReply: contact.assignment?.awaiting_customer_reply || false,
contactName: contact.contactProfile?.name || null,
contactCompany: contact.contactProfile?.company || null,
contactNote: contact.contactProfile?.note || null,
contactPhone: contact.contactProfile?.phone || null,
};
}
function areContactListsEqual(currentContacts, nextContacts) {
if (currentContacts.length !== nextContacts.length) return false;
return currentContacts.every((contact, index) => {
const currentComparable = normalizeComparableContact(contact);
const nextComparable = normalizeComparableContact(nextContacts[index]);
return JSON.stringify(currentComparable) === JSON.stringify(nextComparable);
});
}
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function getUserAreas(user) {
const normalizeArea = (area) => {
if (!area) return null;
if (typeof area === 'string') return area;
return area.nome || area.name || null;
};
const areas = (Array.isArray(user?.areas) ? user.areas : []).map(normalizeArea).filter(Boolean);
const primaryArea = normalizeArea(user?.areaPrincipal);
if (primaryArea && !areas.includes(primaryArea)) {
return [primaryArea, ...areas];
}
return areas;
}
function getUserDisplayName(user) {
return user?.name || user?.nome || user?.username || user?.email || 'Atendente';
}
export function useChat() {
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
const currentUserAreas = getUserAreas(currentUser);
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
const [contacts, setContacts] = useState(buildFallbackContacts);
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
const [draft, setDraft] = useState('');
const [attachedFile, setAttachedFile] = useState(null);
const [areaOptions, setAreaOptions] = useState([]);
const [accessUsers, setAccessUsers] = useState([]);
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferArea, setTransferArea] = useState(currentUserAreas[0] || 'Suporte');
const [transferAttendant, setTransferAttendant] = useState('');
const [transferNote, setTransferNote] = useState('');
const [isReplying] = useState(false);
const [isLoadingChats, setIsLoadingChats] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [apiError, setApiError] = useState(null);
const activeContactRef = useRef(activeContactId);
const contactsRef = useRef(contacts);
const activeContact = useMemo(
() => {
const contact = contacts.find((item) => item.id === activeContactId) || contacts[0];
if (!contact || typeof contact.lastMessageFromMe === 'boolean') return contact;
return {
...contact,
lastMessageFromMe: getLastMessageFromMe(messagesByContact[contact.id] || []),
};
},
[contacts, activeContactId, messagesByContact],
);
const messages = messagesByContact[activeContactId] || [];
const transferAreas = areaOptions.length ? areaOptions.map((area) => area.nome) : fallbackTransferAreas;
const selectedTransferArea = areaOptions.find((area) => area.nome === transferArea) || null;
const usersInTransferArea = accessUsers.filter((user) =>
user.areas?.some((area) => area.nome === transferArea) || user.areaPrincipal?.nome === transferArea,
);
const isSameUserArea = currentUserAreas.includes(transferArea);
const attendants = isSameUserArea ? usersInTransferArea : [];
const activeAssignment = activeContact?.assignment || null;
const isAssignedToCurrentUser = Boolean(
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
);
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
const isQueuedForUserArea = Boolean(
activeAssignment?.status === 'queued' &&
(!activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
);
const canAssumeChat = Boolean(activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
const assignmentLabel = activeAssignment?.user_id
? isWaitingCustomerReply
? 'Aguardando resposta do cliente para liberar novas mensagens'
: `Atendimento com ${activeAssignment.user_nome || 'outro atendente'}`
: activeAssignment?.area_nome
? `Na fila de ${activeAssignment.area_nome}`
: 'Sem fila definida';
const transferNoteLabel = activeAssignment?.transfer_note || '';
useEffect(() => {
setSelectedArea(activeContact?.area || 'Sem fila');
}, [activeContact]);
useEffect(() => {
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
}, [transferArea, accessUsers]);
useEffect(() => {
activeContactRef.current = activeContactId;
}, [activeContactId]);
useEffect(() => {
contactsRef.current = contacts;
}, [contacts]);
useEffect(() => {
let isMounted = true;
async function loadAccessData() {
try {
const [options, users] = await Promise.all([getAccessOptions(), getAccessUsers()]);
if (!isMounted) return;
setAreaOptions(options.areas || []);
setAccessUsers(users || []);
} catch {
if (isMounted) {
setAreaOptions([]);
setAccessUsers([]);
}
}
}
loadAccessData();
return () => {
isMounted = false;
};
}, []);
function canSeeContact(contact) {
if (!currentUserAreas.length) return false;
if (!contact.assignment) return false;
if (contact.assignment.status === 'bot_triage') return false;
if (!contact.assignment.area_nome) return false;
if (contact.assignment.user_id && Number(contact.assignment.user_id) === currentUserId) return true;
return currentUserAreas.includes(contact.assignment.area_nome);
}
async function loadChats({ showLoading = false } = {}) {
if (whatsappStatus !== 'CONNECTED') {
const fallbackContacts = buildFallbackContacts();
setContacts((current) => (areContactListsEqual(current, fallbackContacts) ? current : fallbackContacts));
setActiveContactId((current) =>
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
);
setIsLoadingChats(false);
return;
}
if (showLoading) {
setIsLoadingChats(true);
}
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
const data = await response.json();
if (!Array.isArray(data)) return;
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
setContacts((current) => (areContactListsEqual(current, nextContacts) ? current : nextContacts));
setActiveContactId((current) =>
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
);
setApiError(null);
} catch (error) {
setApiError(error.message);
} finally {
setIsLoadingChats(false);
}
}
useEffect(() => {
let isMounted = true;
async function guardedLoadChats() {
if (!isMounted) return;
await loadChats({ showLoading: contactsRef.current.length === 0 });
}
guardedLoadChats();
const intervalId = window.setInterval(guardedLoadChats, 30000);
return () => {
isMounted = false;
window.clearInterval(intervalId);
};
}, [currentUserId, currentUserAreas.join('|'), whatsappStatus]);
useEffect(() => {
if (!activeContactId) return;
let isMounted = true;
async function loadMessages() {
if (!activeContactId.includes('@')) return;
setIsLoadingMessages(true);
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
const data = await response.json();
if (!isMounted || !Array.isArray(data)) return;
const normalizedMessages = dedupeMessages(
data
.map((message) => ({
...normalizeMessage(message),
chatId: activeContactId,
}))
.filter(isDisplayableMessage),
);
setMessagesByContact((current) => ({
...current,
[activeContactId]: normalizedMessages,
}));
updateContact(activeContactId, (contact) => ({
...contact,
lastMessageFromMe: getLastMessageFromMe(normalizedMessages),
}));
setApiError(null);
} catch (error) {
if (isMounted) setApiError(error.message);
} finally {
if (isMounted) setIsLoadingMessages(false);
}
}
loadMessages();
return () => {
isMounted = false;
};
}, [activeContactId]);
useEffect(() => {
if (!incomingMessage) return;
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
if (!contactId) return;
const message = {
...normalizeMessage(incomingMessage),
chatId: contactId,
};
2026-05-19 16:39:01 -03:00
if (!isDisplayableMessage(message)) {
clearIncomingMessage();
return;
}
const preview = getPreviewFromMessage(message);
setMessagesByContact((current) => {
const currentMessages = current[contactId] || [];
const nextMessages = mergeMessageList(currentMessages, message);
if (nextMessages === currentMessages) return current;
return {
...current,
[contactId]: nextMessages,
};
});
setContacts((current) => {
const existing = current.find((contact) => contact.id === contactId);
if (!existing) {
return current;
}
const nextContact = {
...(existing || {
id: contactId,
name: incomingMessage.notifyName || contactId.split('@')[0],
channel: 'WhatsApp',
2026-05-19 16:39:01 -03:00
status: 'away',
area: 'Sem fila',
2026-05-19 16:39:01 -03:00
lastSeen: 'Visto agora',
unread: 0,
assignment: null,
}),
preview,
time: 'Agora',
2026-05-19 16:39:01 -03:00
status: 'away',
lastSeen: 'Última atividade agora',
lastMessageFromMe: Boolean(incomingMessage.fromMe),
unread:
incomingMessage.fromMe || contactId === activeContactRef.current
? 0
: (existing?.unread || 0) + 1,
};
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
});
clearIncomingMessage();
window.setTimeout(() => loadChats({ showLoading: false }), 1200);
}, [incomingMessage, clearIncomingMessage]);
function updateContact(contactId, updater) {
setContacts((current) =>
current.map((contact) => (contact.id === contactId ? updater(contact) : contact)),
);
}
function updateContactPreview(contactId, preview, media) {
updateContact(contactId, (contact) => ({
...contact,
preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview,
time: 'Agora',
unread: 0,
lastMessageFromMe: true,
}));
}
function updateContactProfile(contactId, profile) {
updateContact(contactId, (contact) => ({
...contact,
name: profile.name || contact.name,
contactProfile: profile,
}));
}
async function attachFile(file) {
if (!file) return;
const data = await fileToBase64(file);
setAttachedFile({
name: file.name,
type: file.type || 'application/octet-stream',
data,
});
}
function removeAttachedFile() {
setAttachedFile(null);
}
async function hydrateMessageMedia(contactId, messageId) {
if (!contactId || !messageId) return;
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
),
}));
try {
const response = await fetch(
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
);
if (!response.ok) throw new Error('Falha ao carregar midia.');
const media = await response.json();
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
),
}));
} catch (error) {
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
: message,
),
}));
}
}
async function assumeChat(contactId = activeContactId) {
if (!contactId?.includes('@') || !currentUserId) return null;
const targetContact = contacts.find((contact) => contact.id === contactId) || activeContact;
const targetAssignment = targetContact?.assignment || null;
const areaId = targetContact?.areaId || targetAssignment?.area_id || areaOptions.find((area) => currentUserAreas.includes(area.nome))?.id;
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: contactId,
userId: String(currentUserId),
areaId,
}),
});
if (!response.ok) throw new Error('Nao foi possivel assumir o atendimento.');
const assignment = await response.json();
updateContact(contactId, (contact) => ({
...contact,
assignment,
area: assignment.area_nome || contact.area,
areaId: assignment.area_id || contact.areaId,
}));
setApiError(null);
return assignment;
} catch (error) {
setApiError(error.message);
return null;
}
}
async function releaseChat() {
if (!activeContactId?.includes('@')) return;
const response = await fetch(`${API_BASE_URL}/whatsapp/release/${encodeURIComponent(activeContactId)}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Nao foi possivel sair do atendimento.');
const assignment = await response.json();
updateContact(activeContactId, (contact) => ({
...contact,
assignment,
area: assignment?.area_nome || contact.area,
areaId: assignment?.area_id || contact.areaId,
}));
}
async function sendMessage(messageText = draft, contactId = activeContactId) {
const trimmed = String(messageText || '').trim();
if (!trimmed && !attachedFile) return;
const targetContact = contacts.find((contact) => contact.id === contactId) || activeContact;
const targetAssignment = targetContact?.assignment || null;
const targetIsAssignedToCurrentUser = Boolean(
targetAssignment?.user_id && currentUserId && Number(targetAssignment.user_id) === currentUserId,
);
const targetIsWaitingCustomerReply = Boolean(targetAssignment?.awaiting_customer_reply);
try {
if (!targetIsAssignedToCurrentUser) {
setApiError('Assuma o atendimento antes de responder.');
return;
}
if (targetIsWaitingCustomerReply) {
setApiError('Aguarde o cliente responder antes de enviar novas mensagens.');
return;
}
} catch (error) {
setApiError(error.message);
return;
}
const media = attachedFile
? {
data: attachedFile.data,
mimetype: attachedFile.type,
filename: attachedFile.name,
}
: null;
const newMessage = {
id: `temp-${Date.now()}`,
chatId: contactId,
sender: 'agent',
text: trimmed,
timestamp: Math.floor(Date.now() / 1000),
hasMedia: Boolean(media),
media,
};
setMessagesByContact((current) => ({
...current,
[contactId]: mergeMessageList(current[contactId] || [], newMessage),
}));
updateContactPreview(contactId, trimmed || '[Midia]', media);
if (contactId === activeContactId) {
setDraft('');
}
setAttachedFile(null);
if (!contactId.includes('@')) return;
try {
await fetch(`${API_BASE_URL}/whatsapp/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: contactId,
message: trimmed,
senderName: getUserDisplayName(currentUser),
media,
}),
});
setApiError(null);
updateContact(contactId, (contact) => ({
...contact,
assignment: contact.assignment
? { ...contact.assignment, transfer_note: null }
: contact.assignment,
}));
} catch (error) {
setApiError(error.message);
}
}
async function submitTransfer() {
const note = transferNote.trim();
const areaId = selectedTransferArea?.id;
if (!areaId) {
setApiError('Selecione uma area valida para transferencia.');
return;
}
if (!isAssignedToCurrentUser) {
setApiError('Assuma o atendimento antes de transferir.');
return;
}
const targetUserId = isSameUserArea && transferAttendant ? Number(transferAttendant) : null;
const response = await fetch(`${API_BASE_URL}/whatsapp/transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: activeContactId,
areaId,
userId: targetUserId,
note,
}),
});
if (!response.ok) {
setApiError('Nao foi possivel transferir o atendimento.');
return;
}
const assignment = await response.json();
const transferMessage = targetUserId
? `Transferencia solicitada para ${transferArea}. Obs: ${note || 'Sem observacao.'}`
: `Transferencia enviada para a fila de ${transferArea}. Obs: ${note || 'Sem observacao.'}`;
setMessagesByContact((current) => ({
...current,
[activeContactId]: [
...(current[activeContactId] || []),
{ id: Date.now() + 2, sender: 'system', text: transferMessage },
],
}));
updateContact(activeContactId, (contact) => ({
...contact,
area: assignment.area_nome || transferArea,
areaId: assignment.area_id || areaId,
assignment,
}));
setSelectedArea(transferArea);
setIsTransferOpen(false);
setTransferNote('');
setApiError(null);
}
return {
contacts,
activeContact,
activeContactId,
setActiveContactId,
messages,
draft,
setDraft,
attachedFile,
attachFile,
removeAttachedFile,
sendMessage,
hydrateMessageMedia,
assumeChat,
releaseChat,
canAssumeChat,
canReply,
assignmentLabel,
transferNoteLabel,
isAssignedToCurrentUser,
activeAssignment,
updateContactProfile,
isReplying,
isLoadingChats,
isLoadingMessages,
apiError,
selectedArea,
setSelectedArea,
isTransferOpen,
setIsTransferOpen,
transferArea,
setTransferArea,
transferAreas,
attendants,
isSameUserArea,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
submitTransfer,
};
}