Files
bodyshop/client/src/components/chat-popup/chat-popup.component.jsx
2026-01-09 14:17:56 -05:00

192 lines
7.0 KiB
JavaScript

import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT } from "../../graphql/conversations.queries";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const { socket } = useSocket();
const client = useApolloClient();
// When socket is connected, we do NOT poll (socket should push updates).
// When disconnected, we poll as a fallback.
const [pollInterval, setPollInterval] = useState(0);
// Ensure conversations query runs once on initial page load (component mount).
const hasLoadedConversationsOnceRef = useRef(false);
// Preserve the last known unread aggregate count so the badge doesn't "vanish"
// when UNREAD_CONVERSATION_COUNT gets skipped after socket connects.
const [unreadAggregateCount, setUnreadAggregateCount] = useState(0);
// Lazy query for conversations (executed manually)
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true,
...(pollInterval > 0 ? { pollInterval } : {})
});
// Query for unread count when chat is not visible and socket is not connected.
// (Once socket connects, we stop this query; we keep the last known value in state.)
useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: chatVisible || socket?.connected,
pollInterval: socket?.connected ? 0 : 60 * 1000,
onCompleted: (result) => {
const nextCount = result?.messages_aggregate?.aggregate?.count;
if (typeof nextCount === "number") setUnreadAggregateCount(nextCount);
},
onError: (err) => {
// Keep last known count; do not force badge to zero on transient failures
console.warn("UNREAD_CONVERSATION_COUNT failed:", err?.message || err);
}
});
// Socket connection status -> polling strategy for CONVERSATION_LIST_QUERY
useEffect(() => {
const handleSocketStatus = () => {
if (socket?.connected) {
setPollInterval(0); // skip polling if socket connected
} else {
setPollInterval(60 * 1000); // fallback polling if disconnected
}
};
handleSocketStatus();
if (socket) {
socket.on("connect", handleSocketStatus);
socket.on("disconnect", handleSocketStatus);
}
return () => {
if (socket) {
socket.off("connect", handleSocketStatus);
socket.off("disconnect", handleSocketStatus);
}
};
}, [socket]);
// Run conversations query exactly once on initial load (component mount)
useEffect(() => {
if (hasLoadedConversationsOnceRef.current) return;
hasLoadedConversationsOnceRef.current = true;
getConversations({
variables: { offset: 0 }
}).catch((err) => {
console.error(`Error fetching conversations: ${err?.message || ""}`, err);
});
}, [getConversations]);
const handleManualRefresh = async () => {
try {
if (called && typeof refetch === "function") {
await refetch({ offset: 0 });
} else {
await getConversations({ variables: { offset: 0 } });
}
} catch (err) {
console.error(`Error refreshing conversations: ${err?.message || ""}`, err);
}
};
// Get unread count from the cache (preferred). Fallback to preserved aggregate count.
const unreadCount = (() => {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const conversations = cachedData?.conversations;
if (!Array.isArray(conversations) || conversations.length === 0) {
return unreadAggregateCount;
}
const hasUnreadCounts = conversations.some((c) => c?.messages_aggregate?.aggregate?.count != null);
if (!hasUnreadCounts) {
return unreadAggregateCount;
}
return conversations.reduce((total, conversation) => {
const unread = conversation?.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch {
return unreadAggregateCount;
}
})();
return (
<Badge count={unreadCount}>
<Card size="small">
{chatVisible ? (
<div className="chat-popup">
<Space align="center">
<Typography.Title level={4}>{t("messaging.labels.messaging")}</Typography.Title>
<ChatNewConversation />
<Tooltip title={t("messaging.labels.recentonly")}>
<InfoCircleOutlined />
</Tooltip>
<SyncOutlined style={{ cursor: "pointer" }} onClick={handleManualRefresh} />
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/>
<Row gutter={[8, 8]} className="chat-popup-content">
<Col span={8}>
{loading ? (
<LoadingSpinner />
) : (
<ChatConversationListComponent conversationList={data ? data.conversations : []} />
)}
</Col>
<Col span={16}>{selectedConversation ? <ChatConversationContainer /> : null}</Col>
</Row>
</div>
) : (
<div onClick={() => toggleChatVisible()} style={{ cursor: "pointer" }}>
<MessageOutlined className="chat-popup-info-icon" />
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)}
</Card>
</Badge>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatPopupComponent);