diff --git a/.gitignore b/.gitignore index abef4d4..74ae217 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,5 @@ build/ # Temporary files tmp/ -temp/ \ No newline at end of file +temp/ +tests/.tmp/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1d7ac85 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb7657b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } +} diff --git a/package.json b/package.json index d655357..6772a9b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "lint:check": "eslint ./src/**/*.{ts,tsx} --ext .ts,.tsx", "type-check": "tsc --noEmit", "preview": "vite preview", - "test": "echo \"No tests specified\" && exit 0", + "test": "tsc -p tests/tsconfig.json && node --test tests/.tmp/identity-test/tests/*.test.js", + "test:identity": "tsc -p tests/tsconfig.json && node --test tests/.tmp/identity-test/tests/chat-identity.test.js", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" }, diff --git a/src/lib/chat/identity.ts b/src/lib/chat/identity.ts new file mode 100644 index 0000000..3827dcf --- /dev/null +++ b/src/lib/chat/identity.ts @@ -0,0 +1,64 @@ +type ConversationKey = { + id?: string; + remoteJid?: string; + remoteJidAlt?: string; +}; + +type MessageRecord = { + key?: ConversationKey; +}; + +export function isDirectChatJid(jid: string): boolean { + return jid.endsWith("@s.whatsapp.net") || jid.endsWith("@lid"); +} + +export function messageMatchesConversation(key: ConversationKey | null | undefined, selectedJid: string): boolean { + if (!key) { + return false; + } + + return key.remoteJid === selectedJid || key.remoteJidAlt === selectedJid; +} + +export function mergeMessagesByKeyId(groups: T[][]): T[] { + const seenIds = new Set(); + const merged: T[] = []; + + for (const group of groups) { + for (const record of group) { + const id = record.key?.id; + if (id) { + if (seenIds.has(id)) { + continue; + } + seenIds.add(id); + merged.push(record); + continue; + } + + merged.push(record); + } + } + + return merged; +} + +export function messageRecordsFromResponse(payload: unknown): MessageRecord[] { + if (Array.isArray(payload)) { + return payload; + } + + if ( + payload && + typeof payload === "object" && + "messages" in payload && + payload.messages && + typeof payload.messages === "object" && + "records" in payload.messages && + Array.isArray(payload.messages.records) + ) { + return payload.messages.records; + } + + return []; +} diff --git a/src/lib/chat/message-payload.ts b/src/lib/chat/message-payload.ts new file mode 100644 index 0000000..bb7bf6d --- /dev/null +++ b/src/lib/chat/message-payload.ts @@ -0,0 +1,10 @@ +/** Evolution persists some Android outbound records with messageType set and message null. */ +export const MISSING_CHAT_MESSAGE_FALLBACK = "Message unavailable"; + +export function getChatMessagePayload(message: { message?: T | null } | null | undefined): T | null { + if (message == null || message.message == null) { + return null; + } + + return message.message; +} diff --git a/src/lib/queries/chat/findMessages.ts b/src/lib/queries/chat/findMessages.ts index 292742e..e673da3 100644 --- a/src/lib/queries/chat/findMessages.ts +++ b/src/lib/queries/chat/findMessages.ts @@ -1,9 +1,14 @@ import { useQuery } from "@tanstack/react-query"; +import { isDirectChatJid, mergeMessagesByKeyId, messageMatchesConversation, messageRecordsFromResponse } from "@/lib/chat/identity"; +import { Message } from "@/types/evolution.types"; + import { api } from "../api"; import { UseQueryParams } from "../types"; import { FindMessagesResponse } from "./types"; +export { messageRecordsFromResponse }; + interface IParams { instanceName: string; remoteJid: string; @@ -11,14 +16,24 @@ interface IParams { const queryKey = (params: Partial) => ["chats", "findMessages", JSON.stringify(params)]; -export const findMessages = async ({ instanceName, remoteJid }: IParams) => { - const response = await api.post(`/chat/findMessages/${instanceName}`, { +export const findMessages = async ({ instanceName, remoteJid }: IParams): Promise => { + const url = `/chat/findMessages/${instanceName}`; + const primaryRequest = api.post(url, { where: { key: { remoteJid } }, }); - if (response.data?.messages?.records) { - return response.data.messages.records; - } - return response.data; + const alternateRequest = isDirectChatJid(remoteJid) + ? api + .post(url, { + where: { key: { remoteJidAlt: remoteJid } }, + }) + .then((response) => (messageRecordsFromResponse(response.data) as Message[]).filter((message) => messageMatchesConversation(message.key, remoteJid))) + .catch(() => [] as Message[]) + : Promise.resolve([] as Message[]); + + const [primaryResponse, alternateRecords] = await Promise.all([primaryRequest, alternateRequest]); + const primaryRecords = messageRecordsFromResponse(primaryResponse.data) as Message[]; + + return mergeMessagesByKeyId([primaryRecords, alternateRecords]); }; export const useFindMessages = (props: UseQueryParams & Partial) => { diff --git a/src/pages/instance/Chat/index.tsx b/src/pages/instance/Chat/index.tsx index 07ff877..7ad2068 100644 --- a/src/pages/instance/Chat/index.tsx +++ b/src/pages/instance/Chat/index.tsx @@ -9,6 +9,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { useInstance } from "@/contexts/InstanceContext"; +import { isDirectChatJid } from "@/lib/chat/identity"; import { useFindChats } from "@/lib/queries/chat/findChats"; import { getToken, TOKEN_ID } from "@/lib/queries/token"; import { cn } from "@/lib/utils"; @@ -115,7 +116,7 @@ function Chat() { const visibleChats = useMemo(() => { const isContacts = kind === "contacts"; const filtered = allChats.filter((c) => - isContacts ? c.remoteJid.includes("@s.whatsapp.net") : c.remoteJid.includes("@g.us"), + isContacts ? isDirectChatJid(c.remoteJid) : c.remoteJid.includes("@g.us"), ); if (!search.trim()) return filtered; const q = search.toLowerCase(); diff --git a/src/pages/instance/Chat/messages.tsx b/src/pages/instance/Chat/messages.tsx index 6331202..f6064b2 100644 --- a/src/pages/instance/Chat/messages.tsx +++ b/src/pages/instance/Chat/messages.tsx @@ -9,6 +9,8 @@ import { Textarea } from "@/components/ui/textarea"; import { useInstance } from "@/contexts/InstanceContext"; +import { messageMatchesConversation } from "@/lib/chat/identity"; +import { getChatMessagePayload, MISSING_CHAT_MESSAGE_FALLBACK } from "@/lib/chat/message-payload"; import { useFindChat } from "@/lib/queries/chat/findChat"; import { useFindMessages } from "@/lib/queries/chat/findMessages"; import { useSendMessage, useSendMedia } from "@/lib/queries/chat/sendMessage"; @@ -112,14 +114,10 @@ const DateSeparator = ({ date }: { date: string }) => ( ); -const formatMessageTime = (date: Date, locale: string): string => - date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); +const formatMessageTime = (date: Date, locale: string): string => date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); // WhatsApp-like deterministic color palette per sender -const SENDER_COLORS = [ - "#e91e63", "#9c27b0", "#3f51b5", "#2196f3", "#00bcd4", - "#009688", "#4caf50", "#ff9800", "#f44336", "#795548", -]; +const SENDER_COLORS = ["#e91e63", "#9c27b0", "#3f51b5", "#2196f3", "#00bcd4", "#009688", "#4caf50", "#ff9800", "#f44336", "#795548"]; const getSenderColor = (key: string): string => { let hash = 0; @@ -152,11 +150,16 @@ const getMessageText = (messageObj: any): string => { // Component to render different message types based on messageType const MessageContent = ({ message }: { message: Message }) => { const messageType = message.messageType as string; + const payload = getChatMessagePayload(message); + + if (payload == null) { + return {MISSING_CHAT_MESSAGE_FALLBACK}; + } switch (messageType) { case "conversation": - if (message.message.contactMessage) { - const contactMsg = message.message.contactMessage; + if (payload.contactMessage) { + const contactMsg = payload.contactMessage; return (
@@ -169,8 +172,8 @@ const MessageContent = ({ message }: { message: Message }) => { ); } - if (message.message.locationMessage) { - const locationMsg = message.message.locationMessage; + if (payload.locationMessage) { + const locationMsg = payload.locationMessage; return (
@@ -192,16 +195,16 @@ const MessageContent = ({ message }: { message: Message }) => { ); } - return {getMessageText(message.message)}; + return {getMessageText(payload)}; case "extendedTextMessage": - return {message.message.conversation ?? message.message.extendedTextMessage?.text}; + return {payload.conversation ?? payload.extendedTextMessage?.text}; case "imageMessage": // Use base64 data or mediaUrl for images - const imageBase64 = message.message.base64 ? (message.message.base64.startsWith("data:") ? message.message.base64 : `data:image/jpeg;base64,${message.message.base64}`) : null; + const imageBase64 = payload.base64 ? (payload.base64.startsWith("data:") ? payload.base64 : `data:image/jpeg;base64,${payload.base64}`) : null; - const imageSrc = imageBase64 || message.message.mediaUrl; + const imageSrc = imageBase64 || payload.mediaUrl; return (
@@ -223,15 +226,15 @@ const MessageContent = ({ message }: { message: Message }) => {

Missing base64 data and mediaUrl

)} - {message.message.imageMessage?.caption &&

{message.message.imageMessage.caption}

} + {payload.imageMessage?.caption &&

{payload.imageMessage.caption}

}
); case "videoMessage": // Use base64 data or mediaUrl for videos - const videoBase64 = message.message.base64 ? (message.message.base64.startsWith("data:") ? message.message.base64 : `data:video/mp4;base64,${message.message.base64}`) : null; + const videoBase64 = payload.base64 ? (payload.base64.startsWith("data:") ? payload.base64 : `data:video/mp4;base64,${payload.base64}`) : null; - const videoSrc = videoBase64 || message.message.mediaUrl; + const videoSrc = videoBase64 || payload.mediaUrl; return (
@@ -251,15 +254,15 @@ const MessageContent = ({ message }: { message: Message }) => {

Missing base64 data and mediaUrl

)} - {message.message.videoMessage?.caption &&

{message.message.videoMessage.caption}

} + {payload.videoMessage?.caption &&

{payload.videoMessage.caption}

}
); case "audioMessage": // Use base64 data or mediaUrl for audio - const audioBase64 = message.message.base64 ? (message.message.base64.startsWith("data:") ? message.message.base64 : `data:audio/mpeg;base64,${message.message.base64}`) : null; + const audioBase64 = payload.base64 ? (payload.base64.startsWith("data:") ? payload.base64 : `data:audio/mpeg;base64,${payload.base64}`) : null; - const audioSrc = audioBase64 || message.message.mediaUrl; + const audioSrc = audioBase64 || payload.mediaUrl; return audioSrc ? (
); @@ -605,9 +606,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
- - {formatMessageTime(getMessageTimestamp(message), locale)} - + {formatMessageTime(getMessageTimestamp(message), locale)} ); @@ -636,9 +635,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa {groupedMessages.map((group, groupIndex) => (
- {group.messages.map((message) => - message.key.fromMe ? renderBubbleRight(message) : renderBubbleLeft(message), - )} + {group.messages.map((message) => (message.key.fromMe ? renderBubbleRight(message) : renderBubbleLeft(message)))}
))}
@@ -651,9 +648,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
)}
-
- {instance && } -
+
{instance && }