From 2c7b3285964e608aaae37b73629ef3f750d72399 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 30 Dec 2025 13:41:26 -0500 Subject: [PATCH] Initial --- .../registerMessagingSocketHandlers.js | 64 +++++++- .../chat-conversation-list.component.jsx | 65 ++++---- .../chat-conversation-title.component.jsx | 16 +- .../chat-conversation.component.jsx | 11 +- .../chat-conversation.container.jsx | 154 ++++++++++++++---- .../chat-mark-unread-button.component.jsx | 23 +++ client/src/graphql/conversations.queries.js | 30 +++- client/src/translations/en_us/common.json | 3 +- client/src/translations/es/common.json | 3 +- client/src/translations/fr/common.json | 3 +- 10 files changed, 292 insertions(+), 80 deletions(-) create mode 100644 client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index d5519661e..accee896b 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -267,7 +267,17 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho return; } - const { conversationId, type, job_conversations, messageIds, ...fields } = data; + const { + conversationId, + type, + job_conversations, + messageIds, // used by "conversation-marked-read" + messageIdsMarkedRead, // used by "conversation-marked-unread" + lastUnreadMessageId, // used by "conversation-marked-unread" + unreadCount, // used by "conversation-marked-unread" + ...fields + } = data; + logLocal("handleConversationChanged - Start", data); const updatedAt = new Date().toISOString(); @@ -313,15 +323,65 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho return message; }); }, + // Keep unread badge in sync (badge uses messages_aggregate.aggregate.count) messages_aggregate: () => ({ __typename: "messages_aggregate", aggregate: { __typename: "messages_aggregate_fields", count: 0 } - }) + }), + unreadcnt: () => 0 } }); } break; + case "conversation-marked-unread": { + if (!conversationId) break; + + const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1; + const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : []; + + client.cache.modify({ + id: client.cache.identify({ __typename: "conversations", id: conversationId }), + fields: { + // Bubble the conversation up in the list (since UI sorts by updated_at) + updated_at: () => updatedAt, + + // If details are already cached, flip the read flags appropriately + messages(existingMessages = [], { readField }) { + if (!Array.isArray(existingMessages) || existingMessages.length === 0) return existingMessages; + + return existingMessages.map((msg) => { + const id = readField("id", msg); + + if (lastUnreadMessageId && id === lastUnreadMessageId) { + return { ...msg, read: false }; + } + + if (idsMarkedRead.includes(id)) { + return { ...msg, read: true }; + } + + return msg; + }); + }, + + // Update unread badge + messages_aggregate: () => ({ + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: safeUnreadCount + } + }), + + // Optional: keep legacy/parallel unread field consistent if present + unreadcnt: () => safeUnreadCount + } + }); + + break; + } + case "conversation-created": updateConversationList({ ...fields, job_conversations, updated_at: updatedAt }); break; diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx index c971c118a..a2361ab52 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx +++ b/client/src/components/chat-conversation-list/chat-conversation-list.component.jsx @@ -12,7 +12,7 @@ import _ from "lodash"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import "./chat-conversation-list.styles.scss"; import { useQuery } from "@apollo/client"; -import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js"; +import { GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS } from "../../graphql/phone-number-opt-out.queries.js"; import { phone } from "phone"; import { useTranslation } from "react-i18next"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -29,13 +29,26 @@ const mapDispatchToProps = (dispatch) => ({ function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) { const { t } = useTranslation(); const [, forceUpdate] = useState(false); - const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "")); - const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, { + + const phoneNumbers = useMemo(() => { + return (conversationList || []) + .map((item) => { + try { + const p = phone(item.phone_num, "CA")?.phoneNumber; + return p ? p.replace(/^\+1/, "") : null; + } catch { + return null; + } + }) + .filter(Boolean); + }, [conversationList]); + + const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS_BY_NUMBERS, { variables: { - bodyshopid: bodyshop.id, + bodyshopid: bodyshop?.id, phone_numbers: phoneNumbers }, - skip: !conversationList.length, + skip: !bodyshop?.id || phoneNumbers.length === 0, fetchPolicy: "cache-and-network" }); @@ -58,15 +71,25 @@ function ChatConversationListComponent({ conversationList, selectedConversation, return _.orderBy(conversationList, ["updated_at"], ["desc"]); }, [conversationList]); - const renderConversation = (index, t) => { + const renderConversation = (index) => { const item = sortedConversationList[index]; - const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""); - const hasOptOutEntry = optOutMap.has(normalizedPhone); + + const normalizedPhone = (() => { + try { + return phone(item.phone_num, "CA")?.phoneNumber?.replace(/^\+1/, "") || ""; + } catch { + return ""; + } + })(); + + const hasOptOutEntry = normalizedPhone ? optOutMap.has(normalizedPhone) : false; + const cardContentRight = {item.updated_at}; const cardContentLeft = item.job_conversations.length > 0 ? item.job_conversations.map((j, idx) => {j.job.ro_number}) : null; + const names = <>{_.uniq(item.job_conversations.map((j) => OwnerNameDisplayFunction(j.job)))}; const cardTitle = ( <> @@ -80,9 +103,10 @@ function ChatConversationListComponent({ conversationList, selectedConversation, )} ); + const cardExtra = ( <> - + {hasOptOutEntry && ( }> @@ -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 ( + +