diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js
index b50d02c8a..e19c56a93 100644
--- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js
+++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js
@@ -142,14 +142,47 @@ export const registerMessagingHandlers = ({ socket, client }) => {
});
};
- const handleConversationChanged = (data) => {
+ const handleConversationChanged = async (data) => {
if (!data) return;
const { conversationId, type, job_conversations, ...fields } = data;
-
logLocal("handleConversationChanged", data);
- // Identify the conversation in the Apollo cache
+ const updatedAt = new Date().toISOString();
+
+ 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 // Prevent duplicates
+ )
+ ]
+ : [newConversation];
+
+ client.cache.writeQuery({
+ query: CONVERSATION_LIST_QUERY,
+ variables: { offset: 0 },
+ data: {
+ conversations: updatedList
+ }
+ });
+ } catch (error) {
+ console.error("Error updating conversation list in the cache:", error);
+ }
+ };
+
+ if (type === "conversation-created") {
+ updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
+ return;
+ }
+
const cacheId = client.cache.identify({
__typename: "conversations",
id: conversationId
@@ -161,20 +194,20 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
if (type === "conversation-archived") {
- // Remove all messages associated with this conversation
- const messageRefs = client.cache.readFragment({
- id: cacheId,
- fragment: gql`
- fragment ConversationMessages on conversations {
- messages {
- id
+ try {
+ // Evict messages associated with the conversation
+ const messageRefs = client.cache.readFragment({
+ id: cacheId,
+ fragment: gql`
+ fragment ConversationMessages on conversations {
+ messages {
+ id
+ }
}
- }
- `
- });
+ `
+ });
- if (messageRefs?.messages) {
- messageRefs.messages.forEach((message) => {
+ messageRefs?.messages?.forEach((message) => {
const messageCacheId = client.cache.identify({
__typename: "messages",
id: message.id
@@ -183,39 +216,84 @@ export const registerMessagingHandlers = ({ socket, client }) => {
client.cache.evict({ id: messageCacheId });
}
});
+
+ // Evict the conversation itself
+ client.cache.evict({ id: cacheId });
+ client.cache.gc(); // Trigger garbage collection
+ } catch (error) {
+ console.error("Error archiving conversation:", error);
}
-
- // Evict the conversation itself
- client.cache.evict({ id: cacheId });
- client.cache.gc(); // Trigger garbage collection to clean up unused entries
-
return;
}
+ if (type === "conversation-unarchived") {
+ try {
+ // Fetch the conversation from the database if not already in the cache
+ const existingConversation = client.cache.readQuery({
+ query: GET_CONVERSATION_DETAILS,
+ variables: { conversationId }
+ });
+
+ if (!existingConversation) {
+ const { data: fetchedData } = await client.query({
+ query: GET_CONVERSATION_DETAILS,
+ variables: { conversationId },
+ fetchPolicy: "network-only"
+ });
+
+ if (fetchedData?.conversations_by_pk) {
+ const conversationData = fetchedData.conversations_by_pk;
+
+ // Enrich conversation data
+ const enrichedConversation = {
+ ...conversationData,
+ messages_aggregate: {
+ __typename: "messages_aggregate",
+ aggregate: {
+ __typename: "messages_aggregate_fields",
+ count: conversationData.messages.filter((message) => !message.read && !message.isoutbound).length
+ }
+ },
+ updated_at: updatedAt
+ };
+
+ updateConversationList(enrichedConversation);
+ }
+ }
+
+ // Mark the conversation as unarchived in the cache
+ client.cache.modify({
+ id: cacheId,
+ fields: {
+ archived: () => false,
+ updated_at: () => updatedAt
+ }
+ });
+ } catch (error) {
+ console.error("Error unarchiving conversation:", error);
+ }
+ return;
+ }
+
+ // Handle other types of updates (e.g., marked read, tags added/removed)
client.cache.modify({
id: cacheId,
fields: {
- // This is a catch-all for just sending it fields off conversation
...Object.fromEntries(
- Object.entries(fields).map(([key, value]) => [
- key,
- (cached) => (value !== undefined ? value : cached) // Update with new value or keep existing
- ])
+ Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
),
...(type === "conversation-marked-read" && {
messages_aggregate: () => ({
- aggregate: { count: 0 } // Reset unread count
+ __typename: "messages_aggregate",
+ aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}),
...(type === "tag-added" && {
- job_conversations: (existing = []) => {
- // Merge existing job_conversations with new ones
- return [...existing, ...job_conversations];
- }
+ job_conversations: (existing = []) => [...existing, ...job_conversations]
}),
...(type === "tag-removed" && {
job_conversations: (existing = [], { readField }) =>
- existing.filter((jobConversationRef) => readField("jobid", jobConversationRef) !== data.jobId)
+ existing.filter((jobRef) => readField("jobid", jobRef) !== data.jobId)
})
}
});
diff --git a/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx b/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx
index b0b6054e4..e45ffb9b6 100644
--- a/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx
+++ b/client/src/components/chat-new-conversation/chat-new-conversation.component.jsx
@@ -1,11 +1,12 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
-import React from "react";
+import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
+import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -17,8 +18,10 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
+ const { socket } = useContext(SocketContext);
+
const handleFinish = (values) => {
- openChatByPhone({ phone_num: values.phoneNumber });
+ openChatByPhone({ phone_num: values.phoneNumber, socket });
form.resetFields();
};
diff --git a/client/src/components/chat-open-button/chat-open-button.component.jsx b/client/src/components/chat-open-button/chat-open-button.component.jsx
index d2f0de528..dcd41c041 100644
--- a/client/src/components/chat-open-button/chat-open-button.component.jsx
+++ b/client/src/components/chat-open-button/chat-open-button.component.jsx
@@ -1,6 +1,6 @@
import { notification } from "antd";
import parsePhoneNumber from "libphonenumber-js";
-import React from "react";
+import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -9,6 +9,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
+import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -21,6 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation();
+ const { socket } = useContext(SocketContext);
+
if (!phone) return <>>;
if (!bodyshop.messagingservicesid) return {phone};
@@ -33,7 +36,7 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobi
const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) {
- openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid });
+ openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket });
} else {
notification["error"]({ message: t("messaging.error.invalidphone") });
}
diff --git a/client/src/components/job-at-change/schedule-event.component.jsx b/client/src/components/job-at-change/schedule-event.component.jsx
index e7f70ff65..bdf5f1a85 100644
--- a/client/src/components/job-at-change/schedule-event.component.jsx
+++ b/client/src/components/job-at-change/schedule-event.component.jsx
@@ -3,7 +3,7 @@ import { Button, Divider, Dropdown, Form, Input, notification, Popover, Select,
import parsePhoneNumber from "libphonenumber-js";
import dayjs from "../../utils/day";
import queryString from "query-string";
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ import ScheduleEventNote from "./schedule-event.note.component";
import { useMutation } from "@apollo/client";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
+import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -49,6 +50,8 @@ export function ScheduleEventComponent({
const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [title, setTitle] = useState(event.title);
+ const { socket } = useContext(SocketContext);
+
const blockContent = (
({
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
- setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })),
- openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
- setMessage: (text) => dispatch(setMessage(text))
+ setCardPaymentContext: (context) =>
+ dispatch(
+ setModalContext({
+ context: context,
+ modal: "cardPayment"
+ })
+ )
});
-export function JobPayments({
- job,
- jobRO,
- bodyshop,
- setMessage,
- openChatByPhone,
- setPaymentContext,
- setCardPaymentContext,
- refetch
-}) {
+export function JobPayments({ job, jobRO, bodyshop, setPaymentContext, setCardPaymentContext, refetch }) {
const {
treatments: { ImEXPay }
} = useSplitTreatments({
@@ -133,7 +127,7 @@ export function JobPayments({
}
];
- //Same as in RO guard. If changed, update in both.
+ //Same as in RO guard. If changed, update in both.
const total = useMemo(() => {
return (
job.payments &&
diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
index c9c3b8fb7..1fdeeec61 100644
--- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
+++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx
@@ -4,7 +4,7 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, notification, Popconfirm, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
-import React, { useMemo, useState } from "react";
+import React, { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
@@ -30,6 +30,7 @@ import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
+import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -126,6 +127,7 @@ export function JobsDetailHeaderActions({
const [updateJob] = useMutation(UPDATE_JOB);
const [voidJob] = useMutation(VOID_JOB);
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
+ const { socket } = useContext(SocketContext);
const {
treatments: { ImEXPay }
@@ -299,7 +301,8 @@ export function JobsDetailHeaderActions({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
- jobid: job.id
+ jobid: job.id,
+ socket
});
setMessage(
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
@@ -342,7 +345,8 @@ export function JobsDetailHeaderActions({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
- jobid: job.id
+ jobid: job.id,
+ socket
});
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else {
diff --git a/client/src/components/payments-generate-link/payments-generate-link.component.jsx b/client/src/components/payments-generate-link/payments-generate-link.component.jsx
index b0f4b26b9..f9d84d10f 100644
--- a/client/src/components/payments-generate-link/payments-generate-link.component.jsx
+++ b/client/src/components/payments-generate-link/payments-generate-link.component.jsx
@@ -3,13 +3,14 @@ import { Button, Form, message, Popover, Space } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
import { parsePhoneNumber } from "libphonenumber-js";
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
+import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -28,6 +29,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [paymentLink, setPaymentLink] = useState(null);
+ const { socket } = useContext(SocketContext);
const handleFinish = async ({ amount }) => {
setLoading(true);
@@ -50,7 +52,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
if (p) {
openChatByPhone({
phone_num: p.formatInternational(),
- jobid: job.id
+ jobid: job.id,
+ socket
});
setMessage(
t("payments.labels.smspaymentreminder", {
@@ -106,7 +109,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const p = parsePhoneNumber(job.ownr_ph1, "CA");
openChatByPhone({
phone_num: p.formatInternational(),
- jobid: job.id
+ jobid: job.id,
+ socket
});
setMessage(
t("payments.labels.smspaymentreminder", {
diff --git a/client/src/firebase/firebase.utils.js b/client/src/firebase/firebase.utils.js
index 46a460525..33da2eeac 100644
--- a/client/src/firebase/firebase.utils.js
+++ b/client/src/firebase/firebase.utils.js
@@ -4,7 +4,6 @@ import { getAuth, updatePassword, updateProfile } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
import { store } from "../redux/store";
-import axios from "axios";
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config);
diff --git a/client/src/graphql/conversations.queries.js b/client/src/graphql/conversations.queries.js
index 03bc0426b..373d70691 100644
--- a/client/src/graphql/conversations.queries.js
+++ b/client/src/graphql/conversations.queries.js
@@ -59,6 +59,8 @@ export const GET_CONVERSATION_DETAILS = gql`
id
phone_num
archived
+ updated_at
+ unreadcnt
label
job_conversations {
jobid
@@ -119,6 +121,26 @@ export const CREATE_CONVERSATION = gql`
insert_conversations(objects: $conversation) {
returning {
id
+ phone_num
+ archived
+ label
+ unreadcnt
+ job_conversations {
+ jobid
+ conversationid
+ job {
+ id
+ ownr_fn
+ ownr_ln
+ ownr_co_nm
+ ro_number
+ }
+ }
+ messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
+ aggregate {
+ count
+ }
+ }
}
}
}
diff --git a/client/src/redux/messaging/messaging.sagas.js b/client/src/redux/messaging/messaging.sagas.js
index 86757a427..fa17c6db5 100644
--- a/client/src/redux/messaging/messaging.sagas.js
+++ b/client/src/redux/messaging/messaging.sagas.js
@@ -2,7 +2,13 @@ import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import { all, call, put, select, takeLatest } from "redux-saga/effects";
import { logImEXEvent } from "../../firebase/firebase.utils";
-import { CONVERSATION_ID_BY_PHONE, CREATE_CONVERSATION } from "../../graphql/conversations.queries";
+import {
+ CONVERSATION_ID_BY_PHONE,
+ CONVERSATION_LIST_QUERY,
+ CREATE_CONVERSATION,
+ GET_CONVERSATION_DETAILS,
+ TOGGLE_CONVERSATION_ARCHIVE
+} from "../../graphql/conversations.queries";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import client from "../../utils/GraphQLClient";
import { selectBodyshop } from "../user/user.selectors";
@@ -27,23 +33,24 @@ export function* onOpenChatByPhone() {
export function* openChatByPhone({ payload }) {
logImEXEvent("messaging_open_by_phone");
- const { phone_num, jobid } = payload;
-
+ const { socket, phone_num, jobid } = payload;
const p = parsePhoneNumber(phone_num, "CA");
const bodyshop = yield select(selectBodyshop);
+
try {
const {
data: { conversations }
} = yield client.query({
query: CONVERSATION_ID_BY_PHONE,
variables: { phone: p.number },
- fetchPolicy: 'no-cache'
+ fetchPolicy: "no-cache"
});
if (conversations.length === 0) {
+ // No conversation exists, create a new one
const {
data: {
- insert_conversations: { returning: newConversationsId }
+ insert_conversations: { returning: newConversations }
}
} = yield client.mutate({
mutation: CREATE_CONVERSATION,
@@ -57,26 +64,107 @@ export function* openChatByPhone({ payload }) {
]
}
});
- yield put(setSelectedConversation(newConversationsId[0].id));
- } else if (conversations.length === 1) {
- //got the ID. Open it.
- yield put(setSelectedConversation(conversations[0].id));
- //Check to see if this job ID is already a child of it. If not add the tag.
- if (jobid && !conversations[0].job_conversations.find((jc) => jc.jobid === jobid))
+ const createdConversation = newConversations[0]; // Get the newly created conversation
+
+ // Emit event for new conversation with full details
+ if (socket) {
+ socket.emit("conversation-modified", {
+ bodyshopId: bodyshop.id,
+ type: "conversation-created",
+ ...createdConversation
+ });
+ }
+
+ // Set the newly created conversation as selected
+ yield put(setSelectedConversation(createdConversation.id));
+ } else if (conversations.length === 1) {
+ const conversation = conversations[0];
+
+ if (conversation.archived) {
+ // Conversation is archived, unarchive it in the DB
+ const {
+ data: { update_conversations_by_pk: updatedConversation }
+ } = yield client.mutate({
+ mutation: TOGGLE_CONVERSATION_ARCHIVE,
+ variables: {
+ id: conversation.id,
+ archived: false
+ }
+ });
+
+ if (socket) {
+ socket.emit("conversation-modified", {
+ type: "conversation-unarchived",
+ conversationId: updatedConversation.id,
+ bodyshopId: bodyshop.id,
+ archived: false
+ });
+ }
+
+ // Update the conversation list in the cache
+ const existingConversations = client.cache.readQuery({
+ query: CONVERSATION_LIST_QUERY,
+ variables: { offset: 0 }
+ });
+
+ client.cache.writeQuery({
+ query: CONVERSATION_LIST_QUERY,
+ variables: { offset: 0 },
+ data: {
+ conversations: [
+ {
+ ...conversation,
+ archived: false,
+ updated_at: new Date().toISOString()
+ },
+ ...(existingConversations?.conversations || [])
+ ]
+ }
+ });
+ }
+
+ // Check if the conversation exists in the cache
+ const cacheId = client.cache.identify({
+ __typename: "conversations",
+ id: conversation.id
+ });
+
+ if (!cacheId) {
+ // Fetch the conversation details from the database
+ const { data } = yield client.query({
+ query: GET_CONVERSATION_DETAILS,
+ variables: { conversationId: conversation.id }
+ });
+
+ // Write fetched data to the cache
+ client.cache.writeQuery({
+ query: GET_CONVERSATION_DETAILS,
+ variables: { conversationId: conversation.id },
+ data
+ });
+ }
+
+ // Open the conversation
+ yield put(setSelectedConversation(conversation.id));
+
+ // Check and add job tag if needed
+ if (jobid && !conversation.job_conversations.find((jc) => jc.jobid === jobid)) {
yield client.mutate({
mutation: INSERT_CONVERSATION_TAG,
variables: {
- conversationId: conversations[0].id,
+ conversationId: conversation.id,
jobId: jobid
}
});
+ }
} else {
- console.log("ERROR: Multiple conversations found. ");
+ // Multiple conversations found
+ console.error("ERROR: Multiple conversations found.");
yield put(setSelectedConversation(null));
}
} catch (error) {
- console.log("Error in sendMessage saga.", error);
+ console.error("Error in openChatByPhone saga.", error);
}
}