}>
@@ -92,6 +116,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
)}
>
);
+
const getCardStyle = () =>
item.id === selectedConversation
? { backgroundColor: "var(--card-selected-bg)" }
@@ -104,24 +129,8 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
-
- {cardContentLeft}
-
-
- {cardContentRight}
-
+ {cardContentLeft}
+ {cardContentRight}
);
@@ -131,7 +140,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
renderConversation(index, t)}
+ itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
/>
diff --git a/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx b/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx
index 86b8cca60..9c3bbaf11 100644
--- a/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx
+++ b/client/src/components/chat-conversation-title/chat-conversation-title.component.jsx
@@ -5,24 +5,24 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
-import { createStructuredSelector } from "reselect";
-import { connect } from "react-redux";
+import ChatMarkUnreadButton from "../chat-mark-unread-button/chat-mark-unread-button.component";
-const mapStateToProps = createStructuredSelector({});
-
-const mapDispatchToProps = () => ({});
-
-export function ChatConversationTitle({ conversation }) {
+export function ChatConversationTitle({ conversation, onMarkUnread, markUnreadDisabled, markUnreadLoading }) {
return (
{conversation?.phone_num}
+
+
+
+
+
);
}
-export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);
+export default ChatConversationTitle;
diff --git a/client/src/components/chat-conversation/chat-conversation.component.jsx b/client/src/components/chat-conversation/chat-conversation.component.jsx
index f5812e34f..c43443812 100644
--- a/client/src/components/chat-conversation/chat-conversation.component.jsx
+++ b/client/src/components/chat-conversation/chat-conversation.component.jsx
@@ -19,7 +19,9 @@ export function ChatConversationComponent({
conversation,
messages,
handleMarkConversationAsRead,
- bodyshop
+ handleMarkLastMessageAsUnread,
+ markingAsUnreadInProgress,
+ canMarkUnread
}) {
const [loading, error] = subState;
@@ -33,7 +35,12 @@ export function ChatConversationComponent({
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}
>
-
+
diff --git a/client/src/components/chat-conversation/chat-conversation.container.jsx b/client/src/components/chat-conversation/chat-conversation.container.jsx
index e53ec8172..ed90b9053 100644
--- a/client/src/components/chat-conversation/chat-conversation.container.jsx
+++ b/client/src/components/chat-conversation/chat-conversation.container.jsx
@@ -1,6 +1,6 @@
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
@@ -18,8 +18,8 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useSocket();
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
+ const [markingAsUnreadInProgress, setMarkingAsUnreadInProgress] = useState(false);
- // Fetch conversation details
const {
loading: convoLoading,
error: convoError,
@@ -27,24 +27,23 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
} = useQuery(GET_CONVERSATION_DETAILS, {
variables: { conversationId: selectedConversation },
fetchPolicy: "network-only",
- nextFetchPolicy: "network-only"
+ nextFetchPolicy: "network-only",
+ skip: !selectedConversation
});
- // Subscription for conversation updates
+ const conversation = convoData?.conversations_by_pk;
+
+ // Subscription for conversation updates (used when socket is NOT connected)
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
- skip: socket?.connected,
+ skip: socket?.connected || !selectedConversation,
variables: { conversationId: selectedConversation },
onData: ({ data: subscriptionResult, client }) => {
- // Extract the messages array from the result
const messages = subscriptionResult?.data?.messages;
- if (!messages || messages.length === 0) {
- console.warn("No messages found in subscription result.");
- return;
- }
+ if (!messages || messages.length === 0) return;
messages.forEach((message) => {
const messageRef = client.cache.identify(message);
- // Write the new message to the cache
+
client.cache.writeFragment({
id: messageRef,
fragment: gql`
@@ -64,7 +63,6 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
data: message
});
- // Update the conversation cache to include the new message
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
fields: {
@@ -82,6 +80,28 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
}
});
+ /**
+ * Best-effort badge update:
+ * This assumes your list query uses messages_aggregate.aggregate.count as UNREAD inbound count.
+ * If it’s total messages, rename/create a dedicated unread aggregate in the list query and update that field instead.
+ */
+ const setConversationUnreadCountBestEffort = useCallback(
+ (conversationId, unreadCount) => {
+ if (!conversationId) return;
+
+ client.cache.modify({
+ id: client.cache.identify({ __typename: "conversations", id: conversationId }),
+ fields: {
+ messages_aggregate(existing) {
+ if (!existing?.aggregate) return existing;
+ return { ...existing, aggregate: { ...existing.aggregate, count: unreadCount } };
+ }
+ }
+ });
+ },
+ [client.cache]
+ );
+
const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => {
if (!conversationId || !messageIds?.length) return;
@@ -89,13 +109,34 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
messageIds.forEach((messageId) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }),
- fields: {
- read: () => true
- }
+ fields: { read: () => true }
});
});
+
+ setConversationUnreadCountBestEffort(conversationId, 0);
},
- [client.cache]
+ [client.cache, setConversationUnreadCountBestEffort]
+ );
+
+ const applyUnreadStateWithMaxOneUnread = useCallback(
+ ({ conversationId, lastUnreadMessageId, messageIdsMarkedRead = [], unreadCount = 1 }) => {
+ if (!conversationId || !lastUnreadMessageId) return;
+
+ messageIdsMarkedRead.forEach((id) => {
+ client.cache.modify({
+ id: client.cache.identify({ __typename: "messages", id }),
+ fields: { read: () => true }
+ });
+ });
+
+ client.cache.modify({
+ id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }),
+ fields: { read: () => false }
+ });
+
+ setConversationUnreadCountBestEffort(conversationId, unreadCount);
+ },
+ [client.cache, setConversationUnreadCountBestEffort]
);
// WebSocket event handlers
@@ -103,20 +144,25 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
if (!socket?.connected) return;
const handleConversationChange = (data) => {
- if (data.type === "conversation-marked-read") {
- const { conversationId, messageIds } = data;
- updateCacheWithReadMessages(conversationId, messageIds);
+ if (data?.type === "conversation-marked-read") {
+ updateCacheWithReadMessages(data.conversationId, data.messageIds);
+ }
+
+ if (data?.type === "conversation-marked-unread") {
+ applyUnreadStateWithMaxOneUnread({
+ conversationId: data.conversationId,
+ lastUnreadMessageId: data.lastUnreadMessageId,
+ messageIdsMarkedRead: data.messageIdsMarkedRead,
+ unreadCount: data.unreadCount
+ });
}
};
socket.on("conversation-changed", handleConversationChange);
+ return () => socket.off("conversation-changed", handleConversationChange);
+ }, [socket, updateCacheWithReadMessages, applyUnreadStateWithMaxOneUnread]);
- return () => {
- socket.off("conversation-changed", handleConversationChange);
- };
- }, [socket, updateCacheWithReadMessages]);
-
- // Join and leave conversation via WebSocket
+ // Join/leave conversation via WebSocket
useEffect(() => {
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
@@ -133,15 +179,21 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
};
}, [socket, bodyshop, selectedConversation]);
- // Mark conversation as read
- const handleMarkConversationAsRead = async () => {
- if (!convoData || markingAsReadInProgress) return;
+ const inboundNonSystemMessages = useMemo(() => {
+ const msgs = conversation?.messages || [];
+ return msgs
+ .filter((m) => m && !m.isoutbound && !m.is_system)
+ .slice()
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ }, [conversation?.messages]);
- const conversation = convoData.conversations_by_pk;
- if (!conversation) return;
+ const canMarkUnread = inboundNonSystemMessages.length > 0;
+
+ const handleMarkConversationAsRead = async () => {
+ if (!conversation || markingAsReadInProgress) return;
const unreadMessageIds = conversation.messages
- ?.filter((message) => !message.read && !message.isoutbound)
+ ?.filter((message) => !message.read && !message.isoutbound && !message.is_system)
.map((message) => message.id);
if (unreadMessageIds?.length > 0) {
@@ -162,12 +214,48 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
}
};
+ const handleMarkLastMessageAsUnread = async () => {
+ if (!conversation || markingAsUnreadInProgress) return;
+ if (!bodyshop?.id || !bodyshop?.imexshopid) return;
+
+ const lastInbound = inboundNonSystemMessages[inboundNonSystemMessages.length - 1];
+ if (!lastInbound?.id) return;
+
+ setMarkingAsUnreadInProgress(true);
+ try {
+ const res = await axios.post("/sms/markLastMessageUnread", {
+ conversationId: conversation.id,
+ imexshopid: bodyshop.imexshopid,
+ bodyshopid: bodyshop.id
+ });
+
+ const payload = res?.data || {};
+ if (payload.lastUnreadMessageId) {
+ applyUnreadStateWithMaxOneUnread({
+ conversationId: conversation.id,
+ lastUnreadMessageId: payload.lastUnreadMessageId,
+ messageIdsMarkedRead: payload.messageIdsMarkedRead || [],
+ unreadCount: typeof payload.unreadCount === "number" ? payload.unreadCount : 1
+ });
+ } else {
+ setConversationUnreadCountBestEffort(conversation.id, 0);
+ }
+ } catch (error) {
+ console.error("Error marking last message unread:", error.message);
+ } finally {
+ setMarkingAsUnreadInProgress(false);
+ }
+ };
+
return (
);
}
diff --git a/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx b/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx
new file mode 100644
index 000000000..47c14b9cf
--- /dev/null
+++ b/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx
@@ -0,0 +1,23 @@
+import { MailOutlined } from "@ant-design/icons";
+import { Button, Tooltip } from "antd";
+import { useTranslation } from "react-i18next";
+
+export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) {
+ const { t } = useTranslation();
+
+ return (
+
+ }
+ loading={loading}
+ disabled={disabled}
+ onMouseDown={(e) => e.stopPropagation()} // prevent parent mark-read handler
+ onClick={(e) => {
+ e.stopPropagation();
+ onMarkUnread?.();
+ }}
+ />
+
+ );
+}
diff --git a/client/src/graphql/conversations.queries.js b/client/src/graphql/conversations.queries.js
index 598be73dd..fa308a3ff 100644
--- a/client/src/graphql/conversations.queries.js
+++ b/client/src/graphql/conversations.queries.js
@@ -2,7 +2,27 @@ import { gql } from "@apollo/client";
export const UNREAD_CONVERSATION_COUNT = gql`
query UNREAD_CONVERSATION_COUNT {
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ # How many conversations have at least one unread inbound, non-system message
+ conversations_aggregate(
+ where: {
+ archived: { _eq: false }
+ messages: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }
+ }
+ ) {
+ aggregate {
+ count
+ }
+ }
+
+ # How many unread inbound, non-system messages exist (excluding archived conversations)
+ messages_aggregate(
+ where: {
+ read: { _eq: false }
+ isoutbound: { _eq: false }
+ is_system: { _eq: false }
+ conversation: { archived: { _eq: false } }
+ }
+ ) {
aggregate {
count
}
@@ -19,7 +39,7 @@ export const CONVERSATION_LIST_QUERY = gql`
unreadcnt
archived
label
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
@@ -41,6 +61,7 @@ export const CONVERSATION_SUBSCRIPTION_BY_PK = gql`
subscription CONVERSATION_SUBSCRIPTION_BY_PK($conversationId: uuid!) {
messages(order_by: { created_at: asc_nulls_first }, where: { conversationid: { _eq: $conversationId } }) {
id
+ conversationid
status
text
is_system
@@ -76,6 +97,7 @@ export const GET_CONVERSATION_DETAILS = gql`
}
messages(order_by: { created_at: asc_nulls_first }) {
id
+ conversationid
status
text
is_system
@@ -110,7 +132,7 @@ export const CONVERSATION_ID_BY_PHONE = gql`
ro_number
}
}
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
@@ -139,7 +161,7 @@ export const CREATE_CONVERSATION = gql`
ro_number
}
}
- messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) {
aggregate {
count
}
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index f79a1f166..225519e46 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -2430,7 +2430,8 @@
"selectmedia": "Select Media",
"sentby": "Sent by {{by}} at {{time}}",
"typeamessage": "Send a message...",
- "unarchive": "Unarchive"
+ "unarchive": "Unarchive",
+ "mark_unread": "Mark as Unread"
},
"render": {
"conversation_list": "Conversation List"
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 2a9a6229b..fc5b04306 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -2430,7 +2430,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Enviar un mensaje...",
- "unarchive": ""
+ "unarchive": "",
+ "mark_unread": ""
},
"render": {
"conversation_list": ""
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index 939f7752e..c10e87902 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -2430,7 +2430,8 @@
"selectmedia": "",
"sentby": "",
"typeamessage": "Envoyer un message...",
- "unarchive": ""
+ "unarchive": "",
+ "mark_unread": ""
},
"render": {
"conversation_list": ""