feature/IO-3000-Migrate-MSG-to-Sockets - Progress Checkpoint

Signed-off-by: Dave Richer <dave@imexsystems.ca>
This commit is contained in:
Dave Richer
2024-11-18 19:51:10 -08:00
parent ae0bfad89a
commit 6e6c44f2b9
9 changed files with 238 additions and 183 deletions

View File

@@ -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 = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
@@ -52,6 +47,7 @@ function ChatConversationListComponent({
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0} />;
const getCardStyle = () =>
@@ -60,40 +56,27 @@ function ChatConversationListComponent({
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
return (
<CellMeasurer key={key} cache={cache} parent={parent} columnIndex={0} rowIndex={index}>
<List.Item
onClick={() => setSelectedConversation(item.id)}
style={style}
className={`chat-list-item
${item.id === selectedConversation ? "chat-list-selected-conversation" : null}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
</CellMeasurer>
<List.Item
key={item.id}
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
);
};
return (
<div className="chat-list-container">
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
height={height}
width={width}
rowCount={conversationList.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight === scrollHeight) {
loadMoreConversations();
}
}}
/>
)}
</AutoSizer>
<Virtuoso
data={conversationList}
itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom
/>
</div>
);
}

View File

@@ -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 */
}
}

View File

@@ -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);
});

View File

@@ -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 (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank" rel="noopener noreferrer">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
{messages[index].isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: messages[index].userid,
time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</Tooltip>
{message.status && (
<div className="message-status">
<Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : null}
className="message-icon"
/>
</div>
)}
</div>
{message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,
time: dayjs(message.created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</CellMeasurer>
</div>
);
};
return (
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={(index) => renderMessage(index)}
followOutput="smooth" // Ensure smooth scrolling when new data is appended
style={{ height: "100%", width: "100%" }}
/>
</div>
);
}
const MessageRender = (message) => {
return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
);
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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();
}}
/>
</span>
<SendOutlined
className="imex-flex-row__margin"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<SendOutlined className="imex-flex-row__margin" onClick={handleEnter} />
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={

View File

@@ -1,8 +1,5 @@
import react from "@vitejs/plugin-react";
import { promises as fsPromises } from "fs";
import { createRequire } from "module";
import * as path from "path";
import * as url from "url";
import { createLogger, defineConfig } from "vite";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint";
@@ -18,28 +15,6 @@ process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
const getFormattedTimestamp = () =>
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()
],

View File

@@ -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);