@@ -2,39 +2,65 @@ import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql
|
|||||||
import { gql } from "@apollo/client";
|
import { gql } from "@apollo/client";
|
||||||
|
|
||||||
const logLocal = (message, ...args) => {
|
const logLocal = (message, ...args) => {
|
||||||
if (import.meta.env.PROD) {
|
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
||||||
return;
|
console.log(`==================== ${message} ====================`);
|
||||||
|
console.dir({ ...args });
|
||||||
}
|
}
|
||||||
console.log(`==================== ${message} ====================`);
|
|
||||||
console.dir({ ...args });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Utility function to enrich conversation data
|
||||||
|
const enrichConversation = (conversation, isOutbound) => ({
|
||||||
|
...conversation,
|
||||||
|
updated_at: conversation.updated_at || new Date().toISOString(),
|
||||||
|
unreadcnt: 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
__typename: "conversations"
|
||||||
|
});
|
||||||
|
|
||||||
export const registerMessagingHandlers = ({ socket, client }) => {
|
export const registerMessagingHandlers = ({ socket, client }) => {
|
||||||
if (!(socket && client)) return;
|
if (!(socket && client)) return;
|
||||||
|
|
||||||
const handleNewMessageSummary = async (message) => {
|
const handleNewMessageSummary = async (message) => {
|
||||||
const { conversationId, newConversation, existingConversation, isoutbound } = message;
|
const { conversationId, newConversation, existingConversation, isoutbound } = message;
|
||||||
|
|
||||||
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
|
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
|
||||||
|
|
||||||
const queryVariables = { offset: 0 };
|
const queryVariables = { offset: 0 };
|
||||||
|
|
||||||
// Utility function to enrich conversation data
|
if (!existingConversation && conversationId) {
|
||||||
const enrichConversation = (conversation, isOutbound) => ({
|
// Attempt to read from the cache to determine if this is actually a new conversation
|
||||||
...conversation,
|
try {
|
||||||
updated_at: conversation.updated_at || new Date().toISOString(),
|
const cachedConversation = client.cache.readFragment({
|
||||||
unreadcnt: conversation.unreadcnt || 0,
|
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||||
archived: conversation.archived || false,
|
fragment: gql`
|
||||||
label: conversation.label || null,
|
fragment ExistingConversationCheck on conversations {
|
||||||
job_conversations: conversation.job_conversations || [],
|
id
|
||||||
messages_aggregate: conversation.messages_aggregate || {
|
}
|
||||||
__typename: "messages_aggregate",
|
`
|
||||||
aggregate: {
|
});
|
||||||
__typename: "messages_aggregate_fields",
|
|
||||||
count: isOutbound ? 0 : 1
|
if (cachedConversation) {
|
||||||
|
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
|
||||||
|
conversationId
|
||||||
|
});
|
||||||
|
return handleNewMessageSummary({
|
||||||
|
...message,
|
||||||
|
existingConversation: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
__typename: "conversations"
|
logLocal("handleNewMessageSummary - Cache miss", { conversationId });
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle new conversation
|
// Handle new conversation
|
||||||
if (!existingConversation && newConversation?.phone_num) {
|
if (!existingConversation && newConversation?.phone_num) {
|
||||||
@@ -49,7 +75,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
const existingConversations = queryResults?.conversations || [];
|
const existingConversations = queryResults?.conversations || [];
|
||||||
const enrichedConversation = enrichConversation(newConversation, isoutbound);
|
const enrichedConversation = enrichConversation(newConversation, isoutbound);
|
||||||
|
|
||||||
// Avoid adding duplicate conversations
|
|
||||||
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
|
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
id: "ROOT_QUERY",
|
id: "ROOT_QUERY",
|
||||||
@@ -68,81 +93,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
|
|
||||||
// Handle existing conversation
|
// Handle existing conversation
|
||||||
if (existingConversation) {
|
if (existingConversation) {
|
||||||
let conversationDetails;
|
|
||||||
|
|
||||||
// Attempt to read existing conversation details from cache
|
|
||||||
try {
|
try {
|
||||||
conversationDetails = client.cache.readFragment({
|
|
||||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
|
||||||
fragment: gql`
|
|
||||||
fragment ExistingConversation on conversations {
|
|
||||||
id
|
|
||||||
phone_num
|
|
||||||
updated_at
|
|
||||||
archived
|
|
||||||
label
|
|
||||||
unreadcnt
|
|
||||||
job_conversations {
|
|
||||||
jobid
|
|
||||||
conversationid
|
|
||||||
}
|
|
||||||
messages_aggregate {
|
|
||||||
aggregate {
|
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
`
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logLocal("handleNewMessageSummary - Cache miss for conversation, fetching from server", { conversationId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch conversation details from server if not in cache
|
|
||||||
if (!conversationDetails) {
|
|
||||||
try {
|
|
||||||
const { data } = await client.query({
|
|
||||||
query: GET_CONVERSATION_DETAILS,
|
|
||||||
variables: { conversationId },
|
|
||||||
fetchPolicy: "network-only"
|
|
||||||
});
|
|
||||||
conversationDetails = data?.conversations_by_pk;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch conversation details from server:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that conversation details were retrieved
|
|
||||||
if (!conversationDetails) {
|
|
||||||
console.error("Unable to retrieve conversation details. Skipping cache update.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if the conversation is already in the cache
|
|
||||||
const queryResults = client.cache.readQuery({
|
|
||||||
query: CONVERSATION_LIST_QUERY,
|
|
||||||
variables: queryVariables
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAlreadyInCache = queryResults?.conversations.some((conv) => conv.id === conversationId);
|
|
||||||
|
|
||||||
if (!isAlreadyInCache) {
|
|
||||||
const enrichedConversation = enrichConversation(conversationDetails, isoutbound);
|
|
||||||
|
|
||||||
client.cache.modify({
|
|
||||||
id: "ROOT_QUERY",
|
|
||||||
fields: {
|
|
||||||
conversations(existingConversations = []) {
|
|
||||||
return [enrichedConversation, ...existingConversations];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update fields for the existing conversation in the cache
|
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||||
fields: {
|
fields: {
|
||||||
@@ -166,7 +117,11 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating cache for existing conversation:", error);
|
console.error("Error updating cache for existing conversation:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logLocal("New Conversation Summary finished without work", { message });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewMessageDetailed = (message) => {
|
const handleNewMessageDetailed = (message) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Card, List, Space, Tag } from "antd";
|
import { Badge, Card, List, Space, Tag } from "antd";
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -20,8 +20,25 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
|
||||||
|
// That comma is there for a reason, do not remove it
|
||||||
|
const [, forceUpdate] = useState(false);
|
||||||
|
|
||||||
|
// Re-render every minute
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
|
||||||
|
}, 60000); // 1 minute in milliseconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval); // Cleanup on unmount
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize the sorted conversation list
|
||||||
|
const sortedConversationList = React.useMemo(() => {
|
||||||
|
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
|
||||||
|
}, [conversationList]);
|
||||||
|
|
||||||
const renderConversation = (index) => {
|
const renderConversation = (index) => {
|
||||||
const item = conversationList[index];
|
const item = sortedConversationList[index];
|
||||||
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
|
||||||
const cardContentLeft =
|
const cardContentLeft =
|
||||||
item.job_conversations.length > 0
|
item.job_conversations.length > 0
|
||||||
@@ -64,13 +81,10 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// CAN DO: Can go back into virtuoso for additional fetch
|
|
||||||
// endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-list-container">
|
<div className="chat-list-container">
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
data={conversationList}
|
data={sortedConversationList}
|
||||||
itemContent={(index) => renderConversation(index)}
|
itemContent={(index) => renderConversation(index)}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
|||||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useContext(SocketContext);
|
||||||
|
|
||||||
const handleRemoveTag = (jobId) => {
|
const handleRemoveTag = async (jobId) => {
|
||||||
const convId = jobConversations[0].conversationid;
|
const convId = jobConversations[0].conversationid;
|
||||||
if (!!convId) {
|
if (!!convId) {
|
||||||
removeJobConversation({
|
await removeJobConversation({
|
||||||
variables: {
|
variables: {
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
jobId: jobId
|
jobId: jobId
|
||||||
@@ -38,17 +38,18 @@ export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).then(() => {
|
|
||||||
if (socket) {
|
|
||||||
// Emit the `conversation-modified` event
|
|
||||||
socket.emit("conversation-modified", {
|
|
||||||
bodyshopId: bodyshop.id,
|
|
||||||
conversationId: convId,
|
|
||||||
type: "tag-removed",
|
|
||||||
jobId: jobId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// Emit the `conversation-modified` event
|
||||||
|
socket.emit("conversation-modified", {
|
||||||
|
bodyshopId: bodyshop.id,
|
||||||
|
conversationId: convId,
|
||||||
|
type: "tag-removed",
|
||||||
|
jobId: jobId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logImEXEvent("messaging_remove_job_tag", {
|
logImEXEvent("messaging_remove_job_tag", {
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
jobId: jobId
|
jobId: jobId
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
|||||||
<InfoCircleOutlined />
|
<InfoCircleOutlined />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
|
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
|
||||||
{pollInterval > 0 && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
|
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
<ShrinkOutlined
|
<ShrinkOutlined
|
||||||
onClick={() => toggleChatVisible()}
|
onClick={() => toggleChatVisible()}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
|
|||||||
|
|
||||||
const executeSearch = (v) => {
|
const executeSearch = (v) => {
|
||||||
logImEXEvent("messaging_search_job_tag", { searchTerm: v });
|
logImEXEvent("messaging_search_job_tag", { searchTerm: v });
|
||||||
loadRo(v);
|
loadRo(v).catch((e) => console.error("Error in ChatTagRoContainer executeSearch:", e));
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||||
@@ -41,33 +41,34 @@ export function ChatTagRoContainer({ conversation, bodyshop }) {
|
|||||||
variables: { conversationId: conversation.id }
|
variables: { conversationId: conversation.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInsertTag = (value, option) => {
|
const handleInsertTag = async (value, option) => {
|
||||||
logImEXEvent("messaging_add_job_tag");
|
logImEXEvent("messaging_add_job_tag");
|
||||||
|
|
||||||
insertTag({
|
await insertTag({
|
||||||
variables: { jobId: option.key }
|
variables: { jobId: option.key }
|
||||||
}).then(() => {
|
|
||||||
if (socket) {
|
|
||||||
// Find the job details from the search data
|
|
||||||
const selectedJob = data?.search_jobs.find((job) => job.id === option.key);
|
|
||||||
if (!selectedJob) return;
|
|
||||||
const newJobConversation = {
|
|
||||||
__typename: "job_conversations",
|
|
||||||
jobid: selectedJob.id,
|
|
||||||
conversationid: conversation.id,
|
|
||||||
job: {
|
|
||||||
__typename: "jobs",
|
|
||||||
...selectedJob
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.emit("conversation-modified", {
|
|
||||||
conversationId: conversation.id,
|
|
||||||
bodyshopId: bodyshop.id,
|
|
||||||
type: "tag-added",
|
|
||||||
job_conversations: [newJobConversation]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// Find the job details from the search data
|
||||||
|
const selectedJob = data?.search_jobs.find((job) => job.id === option.key);
|
||||||
|
if (!selectedJob) return;
|
||||||
|
const newJobConversation = {
|
||||||
|
__typename: "job_conversations",
|
||||||
|
jobid: selectedJob.id,
|
||||||
|
conversationid: conversation.id,
|
||||||
|
job: {
|
||||||
|
__typename: "jobs",
|
||||||
|
...selectedJob
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.emit("conversation-modified", {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
bodyshopId: bodyshop.id,
|
||||||
|
type: "tag-added",
|
||||||
|
job_conversations: [newJobConversation]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -55,7 +55,7 @@
|
|||||||
"soap": "^1.1.6",
|
"soap": "^1.1.6",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-adapter": "^2.5.5",
|
"socket.io-adapter": "^2.5.5",
|
||||||
"ssh2-sftp-client": "^10.0.3",
|
"ssh2-sftp-client": "^11.0.0",
|
||||||
"twilio": "^4.23.0",
|
"twilio": "^4.23.0",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"p-limit": "^3.1.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"source-map-explorer": "^2.5.2"
|
"source-map-explorer": "^2.5.2"
|
||||||
},
|
},
|
||||||
@@ -6707,8 +6708,8 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yocto-queue": "^0.1.0"
|
"yocto-queue": "^0.1.0"
|
||||||
},
|
},
|
||||||
@@ -7737,16 +7738,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ssh2-sftp-client": {
|
"node_modules/ssh2-sftp-client": {
|
||||||
"version": "10.0.3",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-10.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-11.0.0.tgz",
|
||||||
"integrity": "sha512-Wlhasz/OCgrlqC8IlBZhF19Uw/X/dHI8ug4sFQybPE+0sDztvgvDf7Om6o7LbRLe68E7XkFZf3qMnqAvqn1vkQ==",
|
"integrity": "sha512-lOjgNYtioYquhtgyHwPryFNhllkuENjvCKkUXo18w/Q4UpEffCnEUBfiOTlwFdKIhG1rhrOGnA6DeKPSF2CP6w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"concat-stream": "^2.0.0",
|
"concat-stream": "^2.0.0",
|
||||||
"promise-retry": "^2.0.1",
|
"promise-retry": "^2.0.1",
|
||||||
"ssh2": "^1.15.0"
|
"ssh2": "^1.15.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.2"
|
"node": ">=18.20.4"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -8691,8 +8693,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user