Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,5 @@ build/

# Temporary files
tmp/
temp/
temp/
tests/.tmp/
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\""
},
Expand Down
64 changes: 64 additions & 0 deletions src/lib/chat/identity.ts
Original file line number Diff line number Diff line change
@@ -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<T extends MessageRecord>(groups: T[][]): T[] {
const seenIds = new Set<string>();
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 [];
}
10 changes: 10 additions & 0 deletions src/lib/chat/message-payload.ts
Original file line number Diff line number Diff line change
@@ -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<T>(message: { message?: T | null } | null | undefined): T | null {
if (message == null || message.message == null) {
return null;
}

return message.message;
}
27 changes: 21 additions & 6 deletions src/lib/queries/chat/findMessages.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
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;
}

const queryKey = (params: Partial<IParams>) => ["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<Message[]> => {
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]);
Comment on lines +19 to +36

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted and fixed in 9dc5e65: the alternate lookup now runs only for direct JIDs and its records are filtered against the selected conversation before merging.

};

export const useFindMessages = (props: UseQueryParams<FindMessagesResponse> & Partial<IParams>) => {
Expand Down
3 changes: 2 additions & 1 deletion src/pages/instance/Chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 33 additions & 39 deletions src/pages/instance/Chat/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -112,14 +114,10 @@ const DateSeparator = ({ date }: { date: string }) => (
</div>
);

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;
Expand Down Expand Up @@ -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 <span className="text-muted-foreground">{MISSING_CHAT_MESSAGE_FALLBACK}</span>;
}

switch (messageType) {
case "conversation":
if (message.message.contactMessage) {
const contactMsg = message.message.contactMessage;
if (payload.contactMessage) {
const contactMsg = payload.contactMessage;
return (
<div className="p-3 bg-muted rounded-lg max-w-xs">
<div className="flex items-center gap-2 mb-2">
Expand All @@ -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 (
<div className="p-3 bg-muted rounded-lg max-w-xs">
<div className="flex items-center gap-2 mb-2">
Expand All @@ -192,16 +195,16 @@ const MessageContent = ({ message }: { message: Message }) => {
);
}

return <span>{getMessageText(message.message)}</span>;
return <span>{getMessageText(payload)}</span>;

case "extendedTextMessage":
return <span>{message.message.conversation ?? message.message.extendedTextMessage?.text}</span>;
return <span>{payload.conversation ?? payload.extendedTextMessage?.text}</span>;

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 (
<div className="flex flex-col gap-2">
Expand All @@ -223,15 +226,15 @@ const MessageContent = ({ message }: { message: Message }) => {
<p className="text-center text-xs text-muted-foreground mt-1">Missing base64 data and mediaUrl</p>
</div>
)}
{message.message.imageMessage?.caption && <p className="text-sm">{message.message.imageMessage.caption}</p>}
{payload.imageMessage?.caption && <p className="text-sm">{payload.imageMessage.caption}</p>}
</div>
);

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 (
<div className="flex flex-col gap-2">
Expand All @@ -251,15 +254,15 @@ const MessageContent = ({ message }: { message: Message }) => {
<p className="text-center text-xs text-muted-foreground mt-1">Missing base64 data and mediaUrl</p>
</div>
)}
{message.message.videoMessage?.caption && <p className="text-sm">{message.message.videoMessage.caption}</p>}
{payload.videoMessage?.caption && <p className="text-sm">{payload.videoMessage.caption}</p>}
</div>
);

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 ? (
<audio controls className="w-full max-w-xs">
Expand All @@ -278,22 +281,22 @@ const MessageContent = ({ message }: { message: Message }) => {
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg max-w-xs">
<div className="text-2xl">📄</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{message.message.documentMessage?.fileName || "Document"}</p>
{message.message.documentMessage?.fileLength && <p className="text-xs text-muted-foreground">{(message.message.documentMessage.fileLength / 1024 / 1024).toFixed(2)} MB</p>}
<p className="font-medium truncate">{payload.documentMessage?.fileName || "Document"}</p>
{payload.documentMessage?.fileLength && <p className="text-xs text-muted-foreground">{(payload.documentMessage.fileLength / 1024 / 1024).toFixed(2)} MB</p>}
</div>
</div>
);

case "stickerMessage":
return <img src={message.message.mediaUrl} alt="Sticker" className="max-w-32 max-h-32 object-contain" />;
return <img src={payload.mediaUrl} alt="Sticker" className="max-w-32 max-h-32 object-contain" />;

default:
// Fallback for unknown message types
return (
<div className="text-xs text-muted-foreground bg-muted p-2 rounded max-w-xs">
<details>
<summary>Unknown message type: {messageType}</summary>
<pre className="mt-2 whitespace-pre-wrap break-all text-xs">{JSON.stringify(message.message, null, 2)}</pre>
<pre className="mt-2 whitespace-pre-wrap break-all text-xs">{JSON.stringify(payload, null, 2)}</pre>
</details>
</div>
);
Expand Down Expand Up @@ -460,7 +463,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
return;
}

if (data?.data?.key?.remoteJid !== remoteJid) {
if (!messageMatchesConversation(data?.data?.key, remoteJid)) {
return;
}

Expand Down Expand Up @@ -581,9 +584,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
<div className="rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground">
<MessageContent message={message} />
</div>
<span className="mt-0.5 block px-1 text-right text-[11px] text-muted-foreground">
{formatMessageTime(getMessageTimestamp(message), locale)}
</span>
<span className="mt-0.5 block px-1 text-right text-[11px] text-muted-foreground">{formatMessageTime(getMessageTimestamp(message), locale)}</span>
</div>
</div>
);
Expand All @@ -605,9 +606,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
<div className="rounded-lg border bg-muted px-3 py-2 text-sm text-foreground">
<MessageContent message={message} />
</div>
<span className="mt-0.5 block px-1 text-[11px] text-muted-foreground">
{formatMessageTime(getMessageTimestamp(message), locale)}
</span>
<span className="mt-0.5 block px-1 text-[11px] text-muted-foreground">{formatMessageTime(getMessageTimestamp(message), locale)}</span>
</div>
</div>
);
Expand Down Expand Up @@ -636,9 +635,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
{groupedMessages.map((group, groupIndex) => (
<div key={groupIndex}>
<DateSeparator date={group.date} />
{group.messages.map((message) =>
message.key.fromMe ? renderBubbleRight(message) : renderBubbleLeft(message),
)}
{group.messages.map((message) => (message.key.fromMe ? renderBubbleRight(message) : renderBubbleLeft(message)))}
</div>
))}
<div ref={lastMessageRef as never} />
Expand All @@ -651,9 +648,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
</div>
)}
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex flex-shrink-0 items-center">
{instance && <MediaOptions instance={instance} setSelectedMedia={setSelectedMedia} />}
</div>
<div className="flex flex-shrink-0 items-center">{instance && <MediaOptions instance={instance} setSelectedMedia={setSelectedMedia} />}</div>
<Textarea
placeholder={t("chat.input.placeholder", { defaultValue: "Digite uma mensagem..." })}
name="message"
Expand All @@ -672,8 +667,7 @@ function Messages({ textareaRef, handleTextareaChange, textareaHeight, lastMessa
size="icon"
onClick={sendMessage}
disabled={(!messageText.trim() && !selectedMedia) || isSending}
className="h-9 w-9 flex-shrink-0 bg-primary text-primary-foreground hover:bg-primary/85 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-50"
>
className="h-9 w-9 flex-shrink-0 bg-primary text-primary-foreground hover:bg-primary/85 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-50">
<Send className="h-4 w-4" />
<span className="sr-only">{t("chat.input.send")}</span>
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/types/evolution.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type Key = {
id: string;
fromMe: boolean;
remoteJid: string;
remoteJidAlt?: string;
participant?: string;
};

Expand Down
Loading