diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index c946d3cd4..a6c7cc33a 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -346,8 +346,13 @@ export const registerMessagingHandlers = ({ socket, client }) => { client.cache.modify({ id: client.cache.identify({ __typename: "conversations", id: conversationId }), fields: { - job_conversations: (existing = [], { readField }) => - existing.filter((jobRef) => readField("jobid", jobRef) !== fields.jobId) + job_conversations: (existing = [], { readField }) => { + return existing.filter((jobRef) => { + // Read the `jobid` field safely, even if the structure is normalized + const jobId = readField("jobid", jobRef); + return jobId !== fields.jobId; + }); + } } }); break; diff --git a/client/src/components/chat-archive-button/chat-archive-button.component.jsx b/client/src/components/chat-archive-button/chat-archive-button.component.jsx index 6572cbefc..66d25b787 100644 --- a/client/src/components/chat-archive-button/chat-archive-button.component.jsx +++ b/client/src/components/chat-archive-button/chat-archive-button.component.jsx @@ -40,7 +40,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) { }; return ( - ); 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 07b48716f..933617cde 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 @@ -15,7 +15,7 @@ const mapDispatchToProps = () => ({}); export function ChatConversationTitle({ conversation }) { return ( - + {conversation && conversation.phone_num} diff --git a/client/src/components/chat-messages-list/chat-message-list.component.jsx b/client/src/components/chat-messages-list/chat-message-list.component.jsx index 00b9204cc..f045cae8c 100644 --- a/client/src/components/chat-messages-list/chat-message-list.component.jsx +++ b/client/src/components/chat-messages-list/chat-message-list.component.jsx @@ -1,53 +1,85 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Virtuoso } from "react-virtuoso"; import { renderMessage } from "./renderMessage"; import "./chat-message-list.styles.scss"; -const SCROLL_DELAY_MS = 50; -const INITIAL_SCROLL_DELAY_MS = 100; - export default function ChatMessageListComponent({ messages }) { const virtuosoRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); + const loadedImagesRef = useRef(0); - // Scroll to the bottom after a short delay when the component mounts + const handleScrollStateChange = (isAtBottom) => { + setAtBottom(isAtBottom); + }; + + const resetImageLoadState = () => { + loadedImagesRef.current = 0; + }; + + const preloadImages = (imagePaths, onComplete) => { + resetImageLoadState(); + + if (imagePaths.length === 0) { + onComplete(); + return; + } + + imagePaths.forEach((url) => { + const img = new Image(); + img.src = url; + img.onload = img.onerror = () => { + loadedImagesRef.current += 1; + if (loadedImagesRef.current === imagePaths.length) { + onComplete(); + } + }; + }); + }; + + // Ensure all images are loaded on initial render useEffect(() => { - const timer = setTimeout(() => { - if (virtuosoRef?.current?.scrollToIndex && messages?.length) { + const imagePaths = messages + .filter((message) => message.image && message.image_path?.length > 0) + .flatMap((message) => message.image_path); + + preloadImages(imagePaths, () => { + if (virtuosoRef.current) { virtuosoRef.current.scrollToIndex({ index: messages.length - 1, - behavior: "auto" // Instantly scroll to the bottom + align: "end", + behavior: "auto" }); } - }, INITIAL_SCROLL_DELAY_MS); + }); + }, [messages]); - // Cleanup the timeout on unmount - return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // ESLint is disabled for this line because we only want this to load once (valid exception) - - // Scroll to the bottom after the new messages are rendered + // Handle scrolling when new messages are added useEffect(() => { - if (virtuosoRef?.current?.scrollToIndex && messages?.length) { - const timeout = setTimeout(() => { + if (!atBottom) return; + + const latestMessage = messages[messages.length - 1]; + const imagePaths = latestMessage?.image_path || []; + + preloadImages(imagePaths, () => { + if (virtuosoRef.current) { virtuosoRef.current.scrollToIndex({ index: messages.length - 1, - align: "end", // Ensure the last message is fully visible - behavior: "smooth" // Smooth scrolling + align: "end", + behavior: "smooth" }); - }, SCROLL_DELAY_MS); // Slight delay to ensure layout recalculates - - // Cleanup timeout on dependency changes - return () => clearTimeout(timeout); - } - }, [messages]); // Triggered when new messages are added + } + }); + }, [messages, atBottom]); return (
renderMessage(messages, index)} // Pass `messages` to renderMessage - followOutput="smooth" // Ensure smooth scrolling when new data is appended + overscan={!!messages.reduce((acc, message) => acc + (message.image_path?.length || 0), 0) ? messages.length : 0} + itemContent={(index) => renderMessage(messages, index)} + followOutput={(isAtBottom) => handleScrollStateChange(isAtBottom)} + initialTopMostItemIndex={messages.length - 1} style={{ height: "100%", width: "100%" }} />
diff --git a/client/src/components/chat-messages-list/chat-message-list.styles.scss b/client/src/components/chat-messages-list/chat-message-list.styles.scss index 7958f02c3..98f5ab8a4 100644 --- a/client/src/components/chat-messages-list/chat-message-list.styles.scss +++ b/client/src/components/chat-messages-list/chat-message-list.styles.scss @@ -1,110 +1,131 @@ -.message-icon { - color: whitesmoke; - border: #000000; - position: absolute; - margin: 0 0.1rem; - bottom: 0.1rem; - right: 0.3rem; - z-index: 5; -} - .chat { - flex: 1; display: flex; flex-direction: column; - margin: 0.8rem 0rem; - overflow: hidden; // Ensure the content scrolls correctly + height: 100%; + width: 100%; +} +.archive-button { + height: 20px; + border-radius: 4px; +} +.chat-title { + margin-bottom: 5px; } - .messages { display: flex; flex-direction: column; - padding: 0.5rem; // Add padding to avoid edge clipping + padding: 0.5rem; // Prevent edge clipping } .message { + position: relative; border-radius: 20px; padding: 0.25rem 0.8rem; + word-wrap: break-word; - .message-img { + &-img { max-width: 10rem; max-height: 10rem; object-fit: contain; margin: 0.2rem; + border-radius: 4px; + } + + &-images { + display: flex; + flex-wrap: wrap; + gap: 8px; } } - -.yours { - align-items: flex-start; +.chat-send-message-button{ + margin: 0.3rem; + padding-left: 0.5rem; + +} +.message-icon { + position: absolute; + bottom: 0.1rem; + right: 0.3rem; + margin: 0 0.1rem; + color: whitesmoke; + z-index: 5; } .msgmargin { - margin-top: 0.1rem; - margin-bottom: 0.1rem; + margin: 0.1rem 0; } -.yours .message { - margin-right: 20%; - background-color: #eee; - position: relative; +.yours, +.mine { + display: flex; + flex-direction: column; + + .message { + position: relative; + + &:last-child:before, + &:last-child:after { + content: ""; + position: absolute; + bottom: 0; + height: 20px; + width: 20px; + z-index: 0; + } + + &:last-child:after { + width: 10px; + background: white; + z-index: 1; + } + } } -.yours .message:last-child:before { - content: ""; - position: absolute; - z-index: 0; - bottom: 0; - left: -7px; - height: 20px; - width: 20px; - background: #eee; - border-bottom-right-radius: 15px; -} - -.yours .message:last-child:after { - content: ""; - position: absolute; - z-index: 1; - bottom: 0; - left: -10px; - width: 10px; - height: 20px; - background: white; - border-bottom-right-radius: 10px; +/* "Yours" (incoming) message styles */ +.yours { + align-items: flex-start; + + .message { + margin-right: 20%; + background-color: #eee; + + &:last-child:before { + left: -7px; + background: #eee; + border-bottom-right-radius: 15px; + } + + &:last-child:after { + left: -10px; + border-bottom-right-radius: 10px; + } + } } +/* "Mine" (outgoing) message styles */ .mine { align-items: flex-end; + + .message { + color: white; + margin-left: 25%; + background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); + padding-bottom: 0.6rem; + + &:last-child:before { + right: -8px; + background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); + border-bottom-left-radius: 15px; + } + + &:last-child:after { + right: -10px; + border-bottom-left-radius: 10px; + } + } } -.mine .message { - color: white; - margin-left: 25%; - background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); - position: relative; - padding-bottom: 0.6rem; -} - -.mine .message:last-child:before { - content: ""; - position: absolute; - z-index: 0; - bottom: 0; - right: -8px; - height: 20px; - width: 20px; - background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); - border-bottom-left-radius: 15px; -} - -.mine .message:last-child:after { - content: ""; - position: absolute; - z-index: 1; - bottom: 0; - right: -10px; - width: 10px; - height: 20px; - background: white; - border-bottom-left-radius: 10px; +.virtuoso-container { + flex: 1; + overflow: auto; } diff --git a/client/src/components/chat-messages-list/renderMessage.jsx b/client/src/components/chat-messages-list/renderMessage.jsx index e6982f72b..b94e69ee0 100644 --- a/client/src/components/chat-messages-list/renderMessage.jsx +++ b/client/src/components/chat-messages-list/renderMessage.jsx @@ -7,28 +7,38 @@ import { DateTimeFormatter } from "../../utils/DateFormatter"; export const renderMessage = (messages, index) => { const message = messages[index]; + return (
- {message.image_path && - message.image_path.map((i, idx) => ( -
- - Received - -
- ))} -
{message.text}
+ {/* Render images if available */} + {message.image && message.image_path?.length > 0 && ( +
+ {message.image_path.map((url, idx) => ( +
+ + Received + +
+ ))} +
+ )} + {/* Render text if available */} + {message.text &&
{message.text}
}
+ + {/* Message status icons */} {message.status && (message.status === "sent" || message.status === "delivered") && (
)}
+ + {/* Outbound message metadata */} {message.isoutbound && (
{i18n.t("messaging.labels.sentby", { diff --git a/client/src/components/chat-send-message/chat-send-message.component.jsx b/client/src/components/chat-send-message/chat-send-message.component.jsx index d95bf039b..824d5e591 100644 --- a/client/src/components/chat-send-message/chat-send-message.component.jsx +++ b/client/src/components/chat-send-message/chat-send-message.component.jsx @@ -81,7 +81,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi /> diff --git a/client/src/components/chat-tag-ro/chat-tag-ro.container.jsx b/client/src/components/chat-tag-ro/chat-tag-ro.container.jsx index 88c25bf81..1bb791b00 100644 --- a/client/src/components/chat-tag-ro/chat-tag-ro.container.jsx +++ b/client/src/components/chat-tag-ro/chat-tag-ro.container.jsx @@ -86,9 +86,10 @@ export function ChatTagRoContainer({ conversation, bodyshop }) { handleSearch={handleSearch} handleInsertTag={handleInsertTag} setOpen={setOpen} + style={{ cursor: "pointer" }} /> ) : ( - setOpen(true)}> + setOpen(true)}> {t("messaging.actions.link")} diff --git a/client/src/utils/GraphQLClient.js b/client/src/utils/GraphQLClient.js index 423c38e54..c60545f02 100644 --- a/client/src/utils/GraphQLClient.js +++ b/client/src/utils/GraphQLClient.js @@ -145,47 +145,34 @@ middlewares.push( const cache = new InMemoryCache({ typePolicies: { - Query: { - fields: { - conversations: offsetLimitPagination() - } - }, conversations: { fields: { job_conversations: { - keyArgs: false, // Indicates that all job_conversations share the same key - merge(existing = [], incoming) { - // Merge existing and incoming job_conversations - const merged = [ - ...existing, - ...incoming.filter( - (incomingItem) => !existing.some((existingItem) => existingItem.__ref === incomingItem.__ref) - ) - ]; - return merged; - } - }, - messages: { - keyArgs: false, // Ignore arguments when determining uniqueness (like `order_by`). merge(existing = [], incoming = [], { readField }) { - const existingIds = new Set(existing.map((message) => readField("id", message))); + const merged = new Map(); - // Merge incoming messages, avoiding duplicates - const merged = [...existing]; - incoming.forEach((message) => { - if (!existingIds.has(readField("id", message))) { - merged.push(message); - } + // Add existing data to the map + existing.forEach((jobConversation) => { + // Use `readField` to get the unique `jobid`, fallback to `__ref` + const jobId = readField("jobid", jobConversation) || jobConversation.__ref; + if (jobId) merged.set(jobId, jobConversation); }); - return merged; + // Add or replace with incoming data + incoming.forEach((jobConversation) => { + // Use `readField` to get the unique `jobid`, fallback to `__ref` + const jobId = readField("jobid", jobConversation) || jobConversation.__ref; + if (jobId) merged.set(jobId, jobConversation); + }); + + // Return the merged data as an array + return Array.from(merged.values()); } } } } } }); - const client = new ApolloClient({ link: ApolloLink.from(middlewares), cache,