release/2025-12-19 - Hardened
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -27,32 +27,52 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
|
|
||||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pollInterval, setPollInterval] = useState(0);
|
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
const client = useApolloClient();
|
||||||
|
|
||||||
// Lazy query for conversations
|
// When socket is connected, we do NOT poll (socket should push updates).
|
||||||
const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
|
// 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",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
skip: !chatVisible,
|
notifyOnNetworkStatusChange: true,
|
||||||
...(pollInterval > 0 ? { pollInterval } : {})
|
...(pollInterval > 0 ? { pollInterval } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query for unread count when chat is not visible
|
// Query for unread count when chat is not visible and socket is not connected.
|
||||||
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
|
// (Once socket connects, we stop this query; we keep the last known value in state.)
|
||||||
|
useQuery(UNREAD_CONVERSATION_COUNT, {
|
||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only",
|
nextFetchPolicy: "network-only",
|
||||||
pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets
|
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
|
// Socket connection status -> polling strategy for CONVERSATION_LIST_QUERY
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSocketStatus = () => {
|
const handleSocketStatus = () => {
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
setPollInterval(15 * 60 * 1000); // 15 minutes
|
setPollInterval(0); // skip polling if socket connected
|
||||||
} else {
|
} else {
|
||||||
setPollInterval(60 * 1000); // 60 seconds
|
setPollInterval(60 * 1000); // fallback polling if disconnected
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,19 +91,32 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
|||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
// Fetch conversations when chat becomes visible
|
// Run conversations query exactly once on initial load (component mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatVisible)
|
if (hasLoadedConversationsOnceRef.current) return;
|
||||||
getConversations({
|
|
||||||
variables: {
|
|
||||||
offset: 0
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
|
|
||||||
});
|
|
||||||
}, [chatVisible, getConversations]);
|
|
||||||
|
|
||||||
// Get unread count from the cache
|
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 = (() => {
|
const unreadCount = (() => {
|
||||||
try {
|
try {
|
||||||
const cachedData = client.readQuery({
|
const cachedData = client.readQuery({
|
||||||
@@ -91,18 +124,23 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
|||||||
variables: { offset: 0 }
|
variables: { offset: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cachedData?.conversations) {
|
const conversations = cachedData?.conversations;
|
||||||
return unreadData?.messages_aggregate?.aggregate?.count;
|
|
||||||
|
if (!Array.isArray(conversations) || conversations.length === 0) {
|
||||||
|
return unreadAggregateCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate unread message count
|
const hasUnreadCounts = conversations.some((c) => c?.messages_aggregate?.aggregate?.count != null);
|
||||||
return cachedData.conversations.reduce((total, conversation) => {
|
if (!hasUnreadCounts) {
|
||||||
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
|
return unreadAggregateCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations.reduce((total, conversation) => {
|
||||||
|
const unread = conversation?.messages_aggregate?.aggregate?.count || 0;
|
||||||
return total + unread;
|
return total + unread;
|
||||||
}, 0);
|
}, 0);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.warn("Unread count not found in cache:", error);
|
return unreadAggregateCount;
|
||||||
return 0; // Fallback if not in cache
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -117,9 +155,12 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
|||||||
<Tooltip title={t("messaging.labels.recentonly")}>
|
<Tooltip title={t("messaging.labels.recentonly")}>
|
||||||
<InfoCircleOutlined />
|
<InfoCircleOutlined />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
|
|
||||||
|
<SyncOutlined style={{ cursor: "pointer" }} onClick={handleManualRefresh} />
|
||||||
|
|
||||||
{!socket?.connected && <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()}
|
||||||
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
|
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user