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 a49c3b4b4..9006e669a 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 @@ -1,7 +1,7 @@ import { Badge, Card, List, Space, Tag } from "antd"; import React from "react"; import { connect } from "react-redux"; -import { AutoSizer, CellMeasurer, CellMeasurerCache, List as VirtualizedList } from "react-virtualized"; +import { Virtuoso } from "react-virtuoso"; import { createStructuredSelector } from "reselect"; import { setSelectedConversation } from "../../redux/messaging/messaging.actions"; import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors"; @@ -25,12 +25,7 @@ function ChatConversationListComponent({ setSelectedConversation, loadMoreConversations }) { - const cache = new CellMeasurerCache({ - fixedWidth: true, - defaultHeight: 60 - }); - - const rowRenderer = ({ index, key, style, parent }) => { + const renderConversation = (index) => { const item = conversationList[index]; const cardContentRight = {item.updated_at}; const cardContentLeft = @@ -52,6 +47,7 @@ function ChatConversationListComponent({ )} ); + const cardExtra = ; const getCardStyle = () => @@ -60,40 +56,27 @@ function ChatConversationListComponent({ : { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" }; return ( - - setSelectedConversation(item.id)} - style={style} - className={`chat-list-item - ${item.id === selectedConversation ? "chat-list-selected-conversation" : null}`} - > - -
{cardContentLeft}
-
{cardContentRight}
-
-
-
+ setSelectedConversation(item.id)} + className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} + > + +
{cardContentLeft}
+
{cardContentRight}
+
+
); }; return (
- - {({ height, width }) => ( - { - if (scrollTop + clientHeight === scrollHeight) { - loadMoreConversations(); - } - }} - /> - )} - + renderConversation(index)} + style={{ height: "100%", width: "100%" }} + endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom + />
); } diff --git a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss index 2922799a2..e6169777c 100644 --- a/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss +++ b/client/src/components/chat-conversation-list/chat-conversation-list.styles.scss @@ -1,7 +1,7 @@ .chat-list-container { - overflow: hidden; - height: 100%; + height: 100%; /* Ensure it takes up the full available height */ border: 1px solid gainsboro; + overflow: auto; /* Allow scrolling for the Virtuoso component */ } .chat-list-item { @@ -14,3 +14,24 @@ color: #ff7a00; } } + +/* Virtuoso item container adjustments */ +.chat-list-container > div { + height: 100%; /* Ensure Virtuoso takes full height */ + display: flex; + flex-direction: column; +} + +/* Add spacing and better alignment for items */ +.chat-list-item { + padding: 0.5rem 0; /* Add spacing between list items */ + + .ant-card { + border-radius: 8px; /* Slight rounding for card edges */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Subtle shadow for better definition */ + } + + &:hover .ant-card { + border-color: #ff7a00; /* Highlight border on hover */ + } +} diff --git a/client/src/components/chat-conversation/chat-conversation.container.jsx b/client/src/components/chat-conversation/chat-conversation.container.jsx index 87ac6c1fc..c4552950a 100644 --- a/client/src/components/chat-conversation/chat-conversation.container.jsx +++ b/client/src/components/chat-conversation/chat-conversation.container.jsx @@ -13,12 +13,12 @@ const mapStateToProps = createStructuredSelector({ }); export function ChatConversationContainer({ bodyshop, selectedConversation }) { - const { socket, clientId } = useContext(SocketContext); + const { socket } = useContext(SocketContext); const [conversationDetails, setConversationDetails] = useState({}); const [messages, setMessages] = useState([]); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error] = useState(null); // Fetch conversation details and messages when a conversation is selected useEffect(() => { @@ -28,8 +28,6 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) { socket.on("conversation-details", (data) => { setConversationDetails(data.conversation); - console.log("HIT HIT HIT"); - console.dir(data); setMessages(data.messages); setLoading(false); }); 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 55aa81dc0..b3590b2fe 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 @@ -2,105 +2,90 @@ import Icon from "@ant-design/icons"; import { Tooltip } from "antd"; import i18n from "i18next"; import dayjs from "../../utils/day"; -import React, { useEffect, useRef } from "react"; +import React, { useRef, useEffect } from "react"; import { MdDone, MdDoneAll } from "react-icons/md"; -import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized"; +import { Virtuoso } from "react-virtuoso"; import { DateTimeFormatter } from "../../utils/DateFormatter"; import "./chat-message-list.styles.scss"; export default function ChatMessageListComponent({ messages }) { - const virtualizedListRef = useRef(null); + const virtuosoRef = useRef(null); - const _cache = new CellMeasurerCache({ - fixedWidth: true, - // minHeight: 50, - defaultHeight: 100 - }); + // Scroll to the bottom after a short delay when the component mounts + useEffect(() => { + const timer = setTimeout(() => { + if (virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ + index: messages.length - 1, + behavior: "auto" // Instantly scroll to the bottom + }); + } + }, 100); // Delay of 100ms to allow rendering + return () => clearTimeout(timer); // Cleanup the timer on unmount + }, [messages.length]); // Run only once on component mount - const scrollToBottom = (renderedrows) => { - //console.log("Scrolling to", messages.length); - // !!virtualizedListRef.current && - // virtualizedListRef.current.scrollToRow(messages.length); - // Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179 - //Scrolling does not work on this version of React. - }; + // Scroll to the bottom after the new messages are rendered + useEffect(() => { + if (virtuosoRef.current) { + // Allow the DOM and Virtuoso to fully render the new data + setTimeout(() => { + virtuosoRef.current.scrollToIndex({ + index: messages.length - 1, + align: "end", // Ensure the last message is fully visible + behavior: "smooth" // Smooth scrolling + }); + }, 50); // Slight delay to ensure layout recalculates + } + }, [messages]); // Triggered when new messages are added - useEffect(scrollToBottom, [messages]); - - const _rowRenderer = ({ index, key, parent, style }) => { + const renderMessage = (index) => { + const message = messages[index]; return ( - - {({ measure, registerChild }) => ( -
-
- {MessageRender(messages[index])} - {StatusRender(messages[index].status)} +
+
+ +
+ {message.image_path && + message.image_path.map((i, idx) => ( +
+ + Received + +
+ ))} +
{message.text}
- {messages[index].isoutbound && ( -
- {i18n.t("messaging.labels.sentby", { - by: messages[index].userid, - time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a") - })} -
- )} +
+ {message.status && ( +
+ +
+ )} +
+ {message.isoutbound && ( +
+ {i18n.t("messaging.labels.sentby", { + by: message.userid, + time: dayjs(message.created_at).format("MM/DD/YYYY @ hh:mm a") + })}
)} - +
); }; return (
- - {({ height, width }) => ( - - )} - + renderMessage(index)} + followOutput="smooth" // Ensure smooth scrolling when new data is appended + style={{ height: "100%", width: "100%" }} + />
); } - -const MessageRender = (message) => { - return ( - -
- {message.image_path && - message.image_path.map((i, idx) => ( -
- - Received - -
- ))} -
{message.text}
-
-
- ); -}; - -const StatusRender = (status) => { - switch (status) { - case "sent": - return ; - case "delivered": - return ; - default: - return null; - } -}; 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 d576fa7b6..7958f02c3 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,37 +1,30 @@ .message-icon { - //position: absolute; - // bottom: 0rem; color: whitesmoke; border: #000000; position: absolute; margin: 0 0.1rem; bottom: 0.1rem; right: 0.3rem; - z-index: 5; } .chat { flex: 1; - //width: 300px; - //border: solid 1px #eee; display: flex; flex-direction: column; margin: 0.8rem 0rem; + overflow: hidden; // Ensure the content scrolls correctly } .messages { - //margin-top: 30px; display: flex; flex-direction: column; + padding: 0.5rem; // Add padding to avoid edge clipping } .message { border-radius: 20px; padding: 0.25rem 0.8rem; - //margin-top: 5px; - // margin-bottom: 5px; - //display: inline-block; .message-img { max-width: 10rem; @@ -56,7 +49,7 @@ position: relative; } -.yours .message.last:before { +.yours .message:last-child:before { content: ""; position: absolute; z-index: 0; @@ -68,7 +61,7 @@ border-bottom-right-radius: 15px; } -.yours .message.last:after { +.yours .message:last-child:after { content: ""; position: absolute; z-index: 1; @@ -88,12 +81,11 @@ color: white; margin-left: 25%; background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); - background-attachment: fixed; position: relative; padding-bottom: 0.6rem; } -.mine .message.last:before { +.mine .message:last-child:before { content: ""; position: absolute; z-index: 0; @@ -102,11 +94,10 @@ height: 20px; width: 20px; background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%); - background-attachment: fixed; border-bottom-left-radius: 15px; } -.mine .message.last:after { +.mine .message:last-child:after { content: ""; position: absolute; z-index: 1; diff --git a/client/src/components/chat-popup/chat-popup.component.jsx b/client/src/components/chat-popup/chat-popup.component.jsx index 06cd43d86..8ea784d64 100644 --- a/client/src/components/chat-popup/chat-popup.component.jsx +++ b/client/src/components/chat-popup/chat-popup.component.jsx @@ -39,7 +39,7 @@ export function ChatPopupComponent({ unreadCount }) { const { t } = useTranslation(); - const { socket, clientId } = useContext(SocketContext); + const { socket } = useContext(SocketContext); // Emit event to open messaging when chat becomes visible useEffect(() => { 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 918be1b5f..9e1710d0d 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 @@ -1,55 +1,66 @@ import { LoadingOutlined, SendOutlined } from "@ant-design/icons"; import { Input, Spin } from "antd"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { logImEXEvent } from "../../firebase/firebase.utils"; -import { sendMessage, setMessage } from "../../redux/messaging/messaging.actions"; +import { setMessage } from "../../redux/messaging/messaging.actions"; import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.selectors"; -import { selectBodyshop } from "../../redux/user/user.selectors"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component"; import ChatPresetsComponent from "../chat-presets/chat-presets.component"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, isSending: selectIsSending, - message: selectMessage + message: selectMessage, + user: selectCurrentUser }); const mapDispatchToProps = (dispatch) => ({ - sendMessage: (message) => dispatch(sendMessage(message)), setMessage: (message) => dispatch(setMessage(message)) }); -function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) { +function ChatSendMessageComponent({ conversation, bodyshop, isSending, message, setMessage, user }) { + const { socket } = useContext(SocketContext); // Access WebSocket instance const inputArea = useRef(null); const [selectedMedia, setSelectedMedia] = useState([]); + const { t } = useTranslation(); + useEffect(() => { inputArea.current.focus(); }, [isSending, setMessage]); - const { t } = useTranslation(); - const handleEnter = () => { const selectedImages = selectedMedia.filter((i) => i.isSelected); if ((message === "" || !message) && selectedImages.length === 0) return; + logImEXEvent("messaging_send_message"); if (selectedImages.length < 11) { - sendMessage({ + const messageData = { + user, to: conversation.phone_num, body: message || "", messagingServiceSid: bodyshop.messagingservicesid, conversationid: conversation.id, selectedMedia: selectedImages, imexshopid: bodyshop.imexshopid - }); + }; + + // Emit the send-message event via WebSocket + socket.emit("send-message", messageData); + setSelectedMedia( selectedMedia.map((i) => { return { ...i, isSelected: false }; }) ); + + // Optionally clear the input message + setMessage(""); } }; @@ -74,15 +85,11 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi onChange={(e) => setMessage(e.target.value)} onPressEnter={(event) => { event.preventDefault(); - if (!!!event.shiftKey) handleEnter(); + if (!event.shiftKey) handleEnter(); }} /> - + new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m."); -/** This is a hack around react-virtualized, should be removed when switching to react-virtuoso */ -const WRONG_CODE = `import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";`; - -function reactVirtualizedFix() { - return { - name: "flat:react-virtualized", - configResolved: async () => { - const require = createRequire(import.meta.url); - const reactVirtualizedPath = require.resolve("react-virtualized"); - const { pathname: reactVirtualizedFilePath } = new url.URL(reactVirtualizedPath, import.meta.url); - const file = reactVirtualizedFilePath.replace( - path.join("dist", "commonjs", "index.js"), - path.join("dist", "es", "WindowScroller", "utils", "onScroll.js") - ); - const code = await fsPromises.readFile(file, "utf-8"); - const modified = code.replace(WRONG_CODE, ""); - await fsPromises.writeFile(file, modified); - } - }; -} -/** End of hack */ - export const logger = createLogger("info", { allowClearScreen: false }); @@ -108,7 +83,6 @@ export default defineConfig({ gcm_sender_id: "103953800507" } }), - reactVirtualizedFix(), react(), eslint() ], diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 044e550b7..de2f8e25a 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -1,7 +1,14 @@ const { admin } = require("../firebase/firebase-handler"); const { MARK_MESSAGES_AS_READ, GET_CONVERSATIONS, GET_CONVERSATION_DETAILS } = require("../graphql-client/queries"); +const logger = require("../utils/logger"); +const { phone } = require("phone"); +const { client: gqlClient } = require("../graphql-client/graphql-client"); +const queries = require("../graphql-client/queries"); +const twilio = require("twilio"); const client = require("../graphql-client/graphql-client").client; +const twilioClient = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY); + const redisSocketEvents = ({ io, redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis @@ -173,7 +180,96 @@ const redisSocketEvents = ({ socket.emit("error", { message: "Failed to mark messages as read" }); } }; - // Mark Messages as Read + + const sendMessage = (data) => { + const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid, user } = data; + console.dir({ data }); + logger.log("sms-outbound", "DEBUG", user.email, null, { + messagingServiceSid: messagingServiceSid, + to: phone(to).phoneNumber, + mediaUrl: selectedMedia.map((i) => i.src), + text: body, + conversationid, + isoutbound: true, + userid: user.email, + image: selectedMedia?.length > 0, + image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : [] + }); + + if (!!to && !!messagingServiceSid && (!!body || !!selectedMedia?.length > 0) && !!conversationid) { + twilioClient.messages + .create({ + body: body, + messagingServiceSid: messagingServiceSid, + to: phone(to).phoneNumber, + mediaUrl: selectedMedia.map((i) => i.src) + }) + .then((message) => { + let newMessage = { + msid: message.sid, + text: body, + conversationid, + isoutbound: true, + userid: user.email, + image: selectedMedia?.length > 0, + image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : [] + }; + gqlClient + .request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid }) + .then((r2) => { + //console.log("Responding GQL Message ID", JSON.stringify(r2)); + logger.log("sms-outbound-success", "DEBUG", user.email, null, { + msid: message.sid, + conversationid + }); + + const data = { + type: "messaging-outbound", + conversationid: newMessage.conversationid || "" + }; + + // TODO Verify + // const messageData = response.insert_messages.returning[0]; + + // Broadcast new message to conversation room + const room = `conversation-${conversationid}`; + io.to(room).emit("new-message", newMessage); + + admin.messaging().send({ + topic: `${imexshopid}-messaging`, + data + }); + }) + .catch((e2) => { + logger.log("sms-outbound-error", "ERROR", user.email, null, { + msid: message.sid, + conversationid, + error: e2 + }); + }); + }) + .catch((e1) => { + logger.log("sms-outbound-error", "ERROR", user.email, null, { + conversationid, + error: e1 + }); + }); + } else { + logger.log("sms-outbound-error", "ERROR", user.email, null, { + type: "missing-parameters", + messagingServiceSid: messagingServiceSid, + to: phone(to).phoneNumber, + text: body, + conversationid, + isoutbound: true, + userid: user.email, + image: selectedMedia?.length > 0, + image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : [] + }); + } + }; + + socket.on("send-message", sendMessage); socket.on("mark-as-read", markAsRead); socket.on("join-conversation", joinConversation); socket.on("open-messaging", openMessaging);