diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index 06d501dca..253fb7027 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -2,7 +2,6 @@ import { useApolloClient } from "@apollo/client"; import { getToken } from "@firebase/messaging"; import axios from "axios"; import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; import { messaging, requestForToken } from "../../firebase/firebase.utils"; import ChatPopupComponent from "../chat-popup/chat-popup.component"; import "./chat-affix.styles.scss"; @@ -10,14 +9,14 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist import { useSocket } from "../../contexts/SocketIO/useSocket.js"; export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { - const { t } = useTranslation(); const client = useApolloClient(); const { socket } = useSocket(); + // 1) FCM subscription (independent of socket handler registration) useEffect(() => { if (!bodyshop?.messagingservicesid) return; - async function SubscribeToTopicForFCMNotification() { + async function subscribeToTopicForFCMNotification() { try { await requestForToken(); await axios.post("/notifications/subscribe", { @@ -32,17 +31,35 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { } } - SubscribeToTopicForFCMNotification(); + subscribeToTopicForFCMNotification(); + }, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]); - // Register WebSocket handlers - if (socket?.connected) { - registerMessagingHandlers({ socket, client, currentUser, bodyshop, t }); + // 2) Register socket handlers as soon as socket is connected (regardless of chatVisible) + useEffect(() => { + if (!socket) return; + if (!bodyshop?.messagingservicesid) return; + if (!bodyshop?.id) return; - return () => { - unregisterMessagingHandlers({ socket }); - }; + // If socket isn't connected yet, ensure no stale handlers remain. + if (!socket.connected) { + unregisterMessagingHandlers({ socket }); + return; } - }, [bodyshop, socket, t, client]); + + // Prevent duplicate listeners if this effect runs more than once. + unregisterMessagingHandlers({ socket }); + + registerMessagingHandlers({ + socket, + client, + currentUser, + bodyshop + }); + + return () => { + unregisterMessagingHandlers({ socket }); + }; + }, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]); if (!bodyshop?.messagingservicesid) return <>; diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index d5519661e..663aab61c 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -13,68 +13,241 @@ const logLocal = (message, ...args) => { } }; -// Utility function to enrich conversation data -const enrichConversation = (conversation, isOutbound) => ({ +const safeIsoNow = () => new Date().toISOString(); +const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_"); + +const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => { + const c = raw || {}; + const id = c.id; + + return { + __typename: "conversations", + id, + phone_num: c.phone_num ?? null, + updated_at: c.updated_at ?? safeIsoNow(), + unreadcnt: typeof c.unreadcnt === "number" ? c.unreadcnt : 0, + archived: c.archived ?? false, + label: c.label ?? null, + + job_conversations: Array.isArray(c.job_conversations) + ? c.job_conversations.map((jc) => { + const job = jc?.job || {}; + const jobId = jc?.jobid ?? job?.id ?? null; + + return { + __typename: "job_conversations", + jobid: jobId, + conversationid: jc?.conversationid ?? id ?? null, + job: { + __typename: "jobs", + id: jobId, + ro_number: job?.ro_number ?? null, + ownr_fn: job?.ownr_fn ?? null, + ownr_ln: job?.ownr_ln ?? null, + ownr_co_nm: job?.ownr_co_nm ?? null + } + }; + }) + : [], + + messages_aggregate: c.messages_aggregate || { + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: isoutbound || isSystem ? 0 : 1 + } + } + }; +}; + +const CONVERSATION_LIST_ITEM_FRAGMENT = gql` + fragment _ConversationListItem on conversations { + id + phone_num + updated_at + unreadcnt + archived + label + job_conversations { + jobid + conversationid + job { + id + ro_number + ownr_fn + ownr_ln + ownr_co_nm + } + } + messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false }, is_system: { _eq: false } }) { + aggregate { + count + } + } + } +`; + +const normalizeMessageForCache = (raw, fallbackConversationId) => { + const m = raw || {}; + + return { + __typename: "messages", + id: m.id, + conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId, + + status: m.status ?? null, + text: m.text ?? "", + is_system: typeof m.is_system === "boolean" ? m.is_system : false, + isoutbound: typeof m.isoutbound === "boolean" ? m.isoutbound : false, + image: typeof m.image === "boolean" ? m.image : false, + image_path: m.image_path ?? null, + userid: m.userid ?? null, + created_at: m.created_at ?? safeIsoNow(), + read: typeof m.read === "boolean" ? m.read : false + }; +}; + +const isConversationDetailsCached = (client, conversationId) => { + try { + return !!client.cache.readQuery({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId } + })?.conversations_by_pk; + } catch { + return false; + } +}; + +const conversationDetailsCached = (client, conversationId) => { + try { + const res = client.cache.readQuery({ + query: GET_CONVERSATION_DETAILS, + variables: { conversationId } + }); + return !!res?.conversations_by_pk; + } catch { + return false; + } +}; + +const messageEntityCached = (client, messageId) => { + const cacheId = client.cache.identify({ __typename: "messages", id: messageId }); + if (!cacheId) return false; + + try { + client.cache.readFragment({ + id: cacheId, + fragment: gql` + fragment _MsgExists on messages { + id + } + ` + }); + return true; + } catch { + return false; + } +}; + +const enrichConversation = (conversation, { isoutbound, isSystem }) => ({ ...conversation, - updated_at: conversation.updated_at || new Date().toISOString(), - unreadcnt: conversation.unreadcnt || 0, - archived: conversation.archived || false, - label: conversation.label || null, + updated_at: conversation.updated_at || safeIsoNow(), + unreadcnt: typeof conversation.unreadcnt === "number" ? conversation.unreadcnt : 0, + archived: conversation.archived ?? false, + label: conversation.label ?? null, job_conversations: conversation.job_conversations || [], messages_aggregate: conversation.messages_aggregate || { __typename: "messages_aggregate", aggregate: { __typename: "messages_aggregate_fields", - count: isOutbound ? 0 : 1 + count: isoutbound || isSystem ? 0 : 1 } }, __typename: "conversations" }); -// Can be uncommonted to test the playback of the notification sound -// window.testTone = () => { -// const notificationSound = new Audio(newMessageSound); -// notificationSound.play().catch((error) => { -// console.error("Error playing notification sound:", error); -// }); -// }; +const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbound, isSystem } = {}) => { + const normalized = normalizeConversationForList(conversationObj, { isoutbound, isSystem }); + if (!normalized?.id) return; + + const convCacheId = client.cache.identify(normalized); + if (!convCacheId) return; + + client.cache.writeFragment({ + id: convCacheId, + fragment: CONVERSATION_LIST_ITEM_FRAGMENT, + data: normalized + }); + + client.cache.modify({ + id: "ROOT_QUERY", + fields: { + conversations(existing = [], { args, readField }) { + if (!args || args.offset !== 0) return existing; + + const archivedEq = args?.where?.archived?._eq; + if (archivedEq === true) return existing; + + const without = existing.filter((c) => readField("id", c) !== normalized.id); + return [{ __ref: convCacheId }, ...without]; + } + } + }); +}; export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => { if (!(socket && client)) return; - const handleNewMessageSummary = async (message) => { - const { conversationId, newConversation, existingConversation, isoutbound } = message; + // Coalesce unread refetches (avoid spamming during bursts) + let unreadRefetchInFlight = null; + const refetchUnreadCount = () => { + if (unreadRefetchInFlight) return; - // True only when DB value is strictly true; falls back to true on cache miss - const isNewMessageSoundEnabled = (client) => { + unreadRefetchInFlight = client + .refetchQueries({ + include: ["UNREAD_CONVERSATION_COUNT"] + }) + .catch(() => { + // best-effort + }) + .finally(() => { + unreadRefetchInFlight = null; + }); + }; + + const handleNewMessageSummary = async (message) => { + const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message; + const isSystem = isSystemMsid(msid); + + const isNewMessageSoundEnabled = (clientInstance) => { try { const email = currentUser?.email; - if (!email) return true; // default allow if we can't resolve user - const res = client.readQuery({ + if (!email) return true; + const res = clientInstance.readQuery({ query: QUERY_ACTIVE_ASSOCIATION_SOUND, variables: { email } }); const flag = res?.associations?.[0]?.new_message_sound; - return flag === true; // strictly true => enabled + return flag === true; } catch { - // If the query hasn't been seeded in cache yet, default ON return true; } }; logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation }); - const queryVariables = { offset: 0 }; - if (!isoutbound) { - // Play notification sound for new inbound message (scoped to bodyshop) if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) { playNewMessageSound(bodyshop.id); } + + // Real-time badge update for affix (best-effort, coalesced) + if (!isSystem) { + refetchUnreadCount(); + } } if (!existingConversation && conversationId) { - // Attempt to read from the cache to determine if this is actually a new conversation try { const cachedConversation = client.cache.readFragment({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), @@ -86,75 +259,54 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho }); if (cachedConversation) { - logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { - conversationId - }); - return handleNewMessageSummary({ - ...message, - existingConversation: true - }); + logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", { conversationId }); + return handleNewMessageSummary({ ...message, existingConversation: true }); } } catch { - logLocal("handleNewMessageSummary - Cache miss", { conversationId }); + // Cache miss is normal } } - // Handle new conversation if (!existingConversation && newConversation?.phone_num) { - logLocal("handleNewMessageSummary - New Conversation", newConversation); - try { - const queryResults = client.cache.readQuery({ - query: CONVERSATION_LIST_QUERY, - variables: queryVariables - }); - - const existingConversations = queryResults?.conversations || []; - const enrichedConversation = enrichConversation(newConversation, isoutbound); - - if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) { - client.cache.modify({ - id: "ROOT_QUERY", - fields: { - conversations(existingConversations = []) { - return [enrichedConversation, ...existingConversations]; - } - } - }); - } + upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem }); } catch (error) { console.error("Error updating cache for new conversation:", error); } return; } - // Handle existing conversation - if (existingConversation) { + if (existingConversation && conversationId) { try { client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), fields: { - updated_at: () => new Date().toISOString(), + updated_at: () => updated_at || safeIsoNow(), archived: () => false, - messages_aggregate(cached = { aggregate: { count: 0 } }) { - const currentCount = cached.aggregate?.count || 0; - if (!isoutbound) { - return { - __typename: "messages_aggregate", - aggregate: { - __typename: "messages_aggregate_fields", - count: currentCount + 1 - } - }; - } - return cached; + + messages_aggregate(cached = null) { + if (isoutbound || isSystem) return cached; + + const currentCount = cached?.aggregate?.count ?? 0; + return { + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: currentCount + 1 + } + }; + }, + + unreadcnt(cached) { + if (isoutbound || isSystem) return cached; + const n = typeof cached === "number" ? cached : 0; + return n + 1; } } }); } catch (error) { console.error("Error updating cache for existing conversation:", error); } - return; } @@ -166,88 +318,78 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho logLocal("handleNewMessageDetailed - Start", message); - try { - // Check if the conversation exists in the cache - const queryResults = client.cache.readQuery({ - query: GET_CONVERSATION_DETAILS, - variables: { conversationId } - }); + if (!conversationId || !isConversationDetailsCached(client, conversationId)) return; - if (!queryResults?.conversations_by_pk) { - console.warn("Conversation not found in cache:", { conversationId }); + try { + const normalized = normalizeMessageForCache(newMessage, conversationId); + + const messageCacheId = client.cache.identify(normalized); + if (!messageCacheId) { + console.warn("handleNewMessageDetailed - Could not identify message for cache", { + conversationId, + newMessageId: newMessage?.id + }); return; } - // Append the new message to the conversation's message list using cache.modify + client.cache.writeFragment({ + id: messageCacheId, + fragment: gql` + fragment _IncomingMessage on messages { + id + conversationid + status + text + is_system + isoutbound + image + image_path + userid + created_at + read + } + `, + data: normalized + }); + client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), fields: { - messages(existingMessages = []) { - return [...existingMessages, newMessage]; + messages(existing = [], { readField }) { + const already = existing.some((ref) => readField("id", ref) === normalized.id); + if (already) return existing; + return [...existing, { __ref: messageCacheId }]; + }, + updated_at() { + return normalized.created_at || safeIsoNow(); } } }); - logLocal("handleNewMessageDetailed - Message appended successfully", { - conversationId, - newMessage - }); + logLocal("handleNewMessageDetailed - Message appended successfully", { conversationId }); } catch (error) { console.error("Error updating conversation messages in cache:", error); } }; const handleMessageChanged = (message) => { - if (!message) { - logLocal("handleMessageChanged - No message provided", message); - return; - } + if (!message?.id) return; logLocal("handleMessageChanged - Start", message); + if (!messageEntityCached(client, message.id)) return; + try { + const msgCacheId = client.cache.identify({ __typename: "messages", id: message.id }); + client.cache.modify({ - id: client.cache.identify({ __typename: "conversations", id: message.conversationid }), + id: msgCacheId, fields: { - messages(existingMessages = [], { readField }) { - return existingMessages.map((messageRef) => { - // Check if this is the message to update - if (readField("id", messageRef) === message.id) { - const currentStatus = readField("status", messageRef); - - // Handle known types of message changes - switch (message.type) { - case "status-changed": - // Prevent overwriting if the current status is already "delivered" - if (currentStatus === "delivered") { - logLocal("handleMessageChanged - Status already delivered, skipping update", { - messageId: message.id - }); - return messageRef; - } - - // Update the status field - return { - ...messageRef, - status: message.status - }; - - case "text-updated": - // Handle changes to the message text - return { - ...messageRef, - text: message.text - }; - - default: - // Log a warning for unhandled message types - logLocal("handleMessageChanged - Unhandled message type", { type: message.type }); - return messageRef; - } - } - - return messageRef; - }); + status(existing) { + return message.type === "status-changed" ? (message.status ?? existing) : existing; + }, + text(existing) { + return message.type === "text-updated" ? (message.text ?? existing) : existing; } } }); @@ -262,149 +404,140 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho }; const handleConversationChanged = async (data) => { - if (!data) { - logLocal("handleConversationChanged - No data provided", data); - return; - } + if (!data?.conversationId) return; + + const { + conversationId, + type, + job_conversations, + messageIds, + messageIdsMarkedRead, + lastUnreadMessageId, + unreadCount, + ...fields + } = data; - const { conversationId, type, job_conversations, messageIds, ...fields } = data; logLocal("handleConversationChanged - Start", data); - const updatedAt = new Date().toISOString(); + const updatedAt = safeIsoNow(); + const detailsCached = conversationDetailsCached(client, conversationId); - const updateConversationList = (newConversation) => { - try { - const existingList = client.cache.readQuery({ - query: CONVERSATION_LIST_QUERY, - variables: { offset: 0 } - }); - - const updatedList = existingList?.conversations - ? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)] - : [newConversation]; // Prevent duplicates - - client.cache.writeQuery({ - query: CONVERSATION_LIST_QUERY, - variables: { offset: 0 }, - data: { - conversations: updatedList - } - }); - - logLocal("handleConversationChanged - Conversation list updated successfully", newConversation); - } catch (error) { - console.error("Error updating conversation list in the cache:", error); - } - }; - - // Handle specific types try { switch (type) { - case "conversation-marked-read": - if (conversationId && messageIds?.length > 0) { - client.cache.modify({ - id: client.cache.identify({ __typename: "conversations", id: conversationId }), - fields: { - messages(existingMessages = [], { readField }) { - return existingMessages.map((message) => { - if (messageIds.includes(readField("id", message))) { - return { ...message, read: true }; - } - return message; - }); - }, - messages_aggregate: () => ({ - __typename: "messages_aggregate", - aggregate: { __typename: "messages_aggregate_fields", count: 0 } - }) - } + case "conversation-marked-read": { + refetchUnreadCount(); + + if (detailsCached && Array.isArray(messageIds)) { + messageIds.forEach((id) => { + if (!messageEntityCached(client, id)) return; + client.cache.modify({ + id: client.cache.identify({ __typename: "messages", id }), + fields: { read: () => true } + }); }); } - break; - case "conversation-created": - updateConversationList({ ...fields, job_conversations, updated_at: updatedAt }); + client.cache.modify({ + id: client.cache.identify({ __typename: "conversations", id: conversationId }), + fields: { + messages_aggregate: () => ({ + __typename: "messages_aggregate", + aggregate: { __typename: "messages_aggregate_fields", count: 0 } + }), + unreadcnt: () => 0, + updated_at: () => updatedAt + } + }); + break; + } + + case "conversation-marked-unread": { + refetchUnreadCount(); + + const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1; + const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : []; + + if (detailsCached) { + idsMarkedRead.forEach((id) => { + if (!messageEntityCached(client, id)) return; + client.cache.modify({ + id: client.cache.identify({ __typename: "messages", id }), + fields: { read: () => true } + }); + }); + + if (lastUnreadMessageId && messageEntityCached(client, lastUnreadMessageId)) { + client.cache.modify({ + id: client.cache.identify({ __typename: "messages", id: lastUnreadMessageId }), + fields: { read: () => false } + }); + } + } + + client.cache.modify({ + id: client.cache.identify({ __typename: "conversations", id: conversationId }), + fields: { + updated_at: () => updatedAt, + messages_aggregate: () => ({ + __typename: "messages_aggregate", + aggregate: { + __typename: "messages_aggregate_fields", + count: safeUnreadCount + } + }), + unreadcnt: () => safeUnreadCount + } + }); + + break; + } + + case "conversation-created": { + // New conversation likely implies new unread inbound message(s) + refetchUnreadCount(); + + const conv = enrichConversation( + { id: conversationId, job_conversations, ...fields, updated_at: updatedAt }, + { isoutbound: false, isSystem: false } + ); + upsertConversationIntoOffsetZeroList(client, conv); + break; + } case "conversation-unarchived": - case "conversation-archived": - try { - const listQueryVariables = { offset: 0 }; - const detailsQueryVariables = { conversationId }; + case "conversation-archived": { + // Keep unread badge correct even if archiving affects counts + refetchUnreadCount(); - // Check if conversation details exist in the cache - const detailsExist = !!client.cache.readQuery({ - query: GET_CONVERSATION_DETAILS, - variables: detailsQueryVariables - }); - - // Refetch conversation list - await client.refetchQueries({ - include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])], - variables: [ - { query: CONVERSATION_LIST_QUERY, variables: listQueryVariables }, - ...(detailsExist - ? [ - { - query: GET_CONVERSATION_DETAILS, - variables: detailsQueryVariables - } - ] - : []) - ] - }); - - logLocal("handleConversationChanged - Refetched queries after state change", { - conversationId, - type - }); - } catch (error) { - console.error("Error refetching queries after conversation state change:", error); - } + await client.refetchQueries({ + include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS] + }); break; + } case "tag-added": { - // Ensure `job_conversations` is properly formatted - const formattedJobConversations = job_conversations.map((jc) => ({ + const formattedJobConversations = (job_conversations || []).map((jc) => ({ __typename: "job_conversations", jobid: jc.jobid || jc.job?.id, conversationid: conversationId, - job: jc.job || { + job: { __typename: "jobs", - id: data.selectedJob.id, - ro_number: data.selectedJob.ro_number, - ownr_co_nm: data.selectedJob.ownr_co_nm, - ownr_fn: data.selectedJob.ownr_fn, - ownr_ln: data.selectedJob.ownr_ln + id: jc.job?.id, + ro_number: jc.job?.ro_number, + ownr_co_nm: jc.job?.ownr_co_nm, + ownr_fn: jc.job?.ownr_fn, + ownr_ln: jc.job?.ownr_ln } })); client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), fields: { - job_conversations: (existing = []) => { - // Ensure no duplicates based on both `conversationid` and `jobid` - const existingLinks = new Set( - existing.map((jc) => { - const jobId = client.cache.readFragment({ - id: client.cache.identify(jc), - fragment: gql` - fragment JobConversationLinkAdded on job_conversations { - jobid - conversationid - } - ` - })?.jobid; - return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link - }) - ); - - const newItems = formattedJobConversations.filter((jc) => { - const uniqueLink = `${jc.jobid}:${jc.conversationid}`; - return !existingLinks.has(uniqueLink); - }); - - return [...existing, ...newItems]; + job_conversations(existing = [], { readField }) { + const seen = new Set(existing.map((x) => readField("jobid", x)).filter(Boolean)); + const incoming = formattedJobConversations.filter((x) => x.jobid && !seen.has(x.jobid)); + return [...existing, ...incoming]; } } }); @@ -412,46 +545,31 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho break; } - case "tag-removed": - try { - const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId }); - - // Evict the specific cache entry for job_conversations - client.cache.evict({ - id: conversationCacheId, - fieldName: "job_conversations" - }); - - // Garbage collect evicted entries - client.cache.gc(); - - logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", { - conversationId, - type - }); - } catch (error) { - console.error("Error refetching queries after conversation state change: (Tag Removed)", error); - } + case "tag-removed": { + const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId }); + client.cache.evict({ + id: conversationCacheId, + fieldName: "job_conversations" + }); + client.cache.gc(); break; + } - default: - logLocal("handleConversationChanged - Unhandled type", { type }); + default: { client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), - fields: { - ...Object.fromEntries( - Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)]) - ) - } + fields: Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)]) + ) }); + } } } catch (error) { console.error("Error handling conversation changes:", { type, error }); } }; - // Existing handler for phone number opt-out const handlePhoneNumberOptedOut = async (data) => { const { bodyshopid, phone_number } = data; logLocal("handlePhoneNumberOptedOut - Start", data); @@ -461,22 +579,18 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho id: "ROOT_QUERY", fields: { phone_number_opt_out(existing = [], { readField }) { - const phoneNumberExists = existing.some( + const exists = existing.some( (ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid ); - - if (phoneNumberExists) { - logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid }); - return existing; - } + if (exists) return existing; const newOptOut = { __typename: "phone_number_opt_out", id: `temporary-${phone_number}-${Date.now()}`, bodyshopid, phone_number, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + created_at: safeIsoNow(), + updated_at: safeIsoNow() }; return [...existing, newOptOut]; @@ -491,46 +605,36 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho args: { bodyshopid, search: phone_number } }); client.cache.gc(); - - logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data); } catch (error) { console.error("Error updating cache for phone number opt-out:", error); - logLocal("handlePhoneNumberOptedOut - Error", { error: error.message }); } }; - // New handler for phone number opt-in const handlePhoneNumberOptedIn = async (data) => { const { bodyshopid, phone_number } = data; logLocal("handlePhoneNumberOptedIn - Start", data); try { - // Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number client.cache.modify({ id: "ROOT_QUERY", fields: { phone_number_opt_out(existing = [], { readField }) { - // Filter out the phone number from the opt-out list return existing.filter( (ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid) ); } }, - broadcast: true // Trigger UI updates + broadcast: true }); - // Evict the cache entry to force a refetch on next query client.cache.evict({ id: "ROOT_QUERY", fieldName: "phone_number_opt_out", args: { bodyshopid, search: phone_number } }); client.cache.gc(); - - logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data); } catch (error) { console.error("Error updating cache for phone number opt-in:", error); - logLocal("handlePhoneNumberOptedIn - Error", { error: error.message }); } }; 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..b8e70a43a --- /dev/null +++ b/client/src/components/chat-mark-unread-button/chat-mark-unread-button.component.jsx @@ -0,0 +1,24 @@ +import { Button, Tooltip } from "antd"; +import { useTranslation } from "react-i18next"; + +export default function ChatMarkUnreadButton({ disabled, loading, onMarkUnread }) { + const { t } = useTranslation(); + + return ( + +