feature/IO-3478-Mark-Conversation-Unread: Make Notifications more realtime
This commit is contained in:
@@ -2,7 +2,6 @@ import { useApolloClient } from "@apollo/client";
|
|||||||
import { getToken } from "@firebase/messaging";
|
import { getToken } from "@firebase/messaging";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||||
import "./chat-affix.styles.scss";
|
import "./chat-affix.styles.scss";
|
||||||
@@ -10,14 +9,14 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
|
|||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
|
|
||||||
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
// 1) FCM subscription (independent of socket handler registration)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bodyshop?.messagingservicesid) return;
|
if (!bodyshop?.messagingservicesid) return;
|
||||||
|
|
||||||
async function SubscribeToTopicForFCMNotification() {
|
async function subscribeToTopicForFCMNotification() {
|
||||||
try {
|
try {
|
||||||
await requestForToken();
|
await requestForToken();
|
||||||
await axios.post("/notifications/subscribe", {
|
await axios.post("/notifications/subscribe", {
|
||||||
@@ -32,17 +31,35 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubscribeToTopicForFCMNotification();
|
subscribeToTopicForFCMNotification();
|
||||||
|
}, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]);
|
||||||
|
|
||||||
// Register WebSocket handlers
|
// 2) Register socket handlers as soon as socket is connected (regardless of chatVisible)
|
||||||
if (socket?.connected) {
|
useEffect(() => {
|
||||||
registerMessagingHandlers({ socket, client, currentUser, bodyshop, t });
|
if (!socket) return;
|
||||||
|
if (!bodyshop?.messagingservicesid) return;
|
||||||
|
if (!bodyshop?.id) return;
|
||||||
|
|
||||||
return () => {
|
// If socket isn't connected yet, ensure no stale handlers remain.
|
||||||
unregisterMessagingHandlers({ socket });
|
if (!socket.connected) {
|
||||||
};
|
unregisterMessagingHandlers({ socket });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [bodyshop, socket, t, client]);
|
|
||||||
|
// Prevent duplicate listeners if this effect runs more than once.
|
||||||
|
unregisterMessagingHandlers({ socket });
|
||||||
|
|
||||||
|
registerMessagingHandlers({
|
||||||
|
socket,
|
||||||
|
client,
|
||||||
|
currentUser,
|
||||||
|
bodyshop
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterMessagingHandlers({ socket });
|
||||||
|
};
|
||||||
|
}, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]);
|
||||||
|
|
||||||
if (!bodyshop?.messagingservicesid) return <></>;
|
if (!bodyshop?.messagingservicesid) return <></>;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const logLocal = (message, ...args) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const safeIsoNow = () => new Date().toISOString();
|
||||||
|
const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_");
|
||||||
|
|
||||||
const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
|
const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
|
||||||
const c = raw || {};
|
const c = raw || {};
|
||||||
const id = c.id;
|
const id = c.id;
|
||||||
@@ -47,7 +50,6 @@ const normalizeConversationForList = (raw, { isoutbound, isSystem } = {}) => {
|
|||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
|
|
||||||
// This is the field your list badge reads (with args). We write it via a fragment with the same args.
|
|
||||||
messages_aggregate: c.messages_aggregate || {
|
messages_aggregate: c.messages_aggregate || {
|
||||||
__typename: "messages_aggregate",
|
__typename: "messages_aggregate",
|
||||||
aggregate: {
|
aggregate: {
|
||||||
@@ -93,7 +95,6 @@ const normalizeMessageForCache = (raw, fallbackConversationId) => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId,
|
conversationid: m.conversationid ?? m.conversation?.id ?? fallbackConversationId,
|
||||||
|
|
||||||
// Fields your UI queries expect
|
|
||||||
status: m.status ?? null,
|
status: m.status ?? null,
|
||||||
text: m.text ?? "",
|
text: m.text ?? "",
|
||||||
is_system: typeof m.is_system === "boolean" ? m.is_system : false,
|
is_system: typeof m.is_system === "boolean" ? m.is_system : false,
|
||||||
@@ -101,7 +102,7 @@ const normalizeMessageForCache = (raw, fallbackConversationId) => {
|
|||||||
image: typeof m.image === "boolean" ? m.image : false,
|
image: typeof m.image === "boolean" ? m.image : false,
|
||||||
image_path: m.image_path ?? null,
|
image_path: m.image_path ?? null,
|
||||||
userid: m.userid ?? null,
|
userid: m.userid ?? null,
|
||||||
created_at: m.created_at ?? new Date().toISOString(),
|
created_at: m.created_at ?? safeIsoNow(),
|
||||||
read: typeof m.read === "boolean" ? m.read : false
|
read: typeof m.read === "boolean" ? m.read : false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -117,10 +118,6 @@ const isConversationDetailsCached = (client, conversationId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSystemMsid = (msid) => typeof msid === "string" && msid.startsWith("SYS_");
|
|
||||||
|
|
||||||
const safeIsoNow = () => new Date().toISOString();
|
|
||||||
|
|
||||||
const conversationDetailsCached = (client, conversationId) => {
|
const conversationDetailsCached = (client, conversationId) => {
|
||||||
try {
|
try {
|
||||||
const res = client.cache.readQuery({
|
const res = client.cache.readQuery({
|
||||||
@@ -152,7 +149,6 @@ const messageEntityCached = (client, messageId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize/enrich conversation data so it matches what CONVERSATION_LIST_QUERY expects
|
|
||||||
const enrichConversation = (conversation, { isoutbound, isSystem }) => ({
|
const enrichConversation = (conversation, { isoutbound, isSystem }) => ({
|
||||||
...conversation,
|
...conversation,
|
||||||
updated_at: conversation.updated_at || safeIsoNow(),
|
updated_at: conversation.updated_at || safeIsoNow(),
|
||||||
@@ -177,14 +173,12 @@ const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbo
|
|||||||
const convCacheId = client.cache.identify(normalized);
|
const convCacheId = client.cache.identify(normalized);
|
||||||
if (!convCacheId) return;
|
if (!convCacheId) return;
|
||||||
|
|
||||||
// Seed the entity in the normalized store so we can safely reference it.
|
|
||||||
client.cache.writeFragment({
|
client.cache.writeFragment({
|
||||||
id: convCacheId,
|
id: convCacheId,
|
||||||
fragment: CONVERSATION_LIST_ITEM_FRAGMENT,
|
fragment: CONVERSATION_LIST_ITEM_FRAGMENT,
|
||||||
data: normalized
|
data: normalized
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert/move it to top of the first page list (offset 0) only.
|
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
id: "ROOT_QUERY",
|
id: "ROOT_QUERY",
|
||||||
fields: {
|
fields: {
|
||||||
@@ -204,9 +198,25 @@ const upsertConversationIntoOffsetZeroList = (client, conversationObj, { isoutbo
|
|||||||
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
|
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
|
||||||
if (!(socket && client)) return;
|
if (!(socket && client)) return;
|
||||||
|
|
||||||
|
// Coalesce unread refetches (avoid spamming during bursts)
|
||||||
|
let unreadRefetchInFlight = null;
|
||||||
|
const refetchUnreadCount = () => {
|
||||||
|
if (unreadRefetchInFlight) return;
|
||||||
|
|
||||||
|
unreadRefetchInFlight = client
|
||||||
|
.refetchQueries({
|
||||||
|
include: ["UNREAD_CONVERSATION_COUNT"]
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// best-effort
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
unreadRefetchInFlight = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleNewMessageSummary = async (message) => {
|
const handleNewMessageSummary = async (message) => {
|
||||||
const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message;
|
const { conversationId, newConversation, existingConversation, isoutbound, msid, updated_at } = message;
|
||||||
|
|
||||||
const isSystem = isSystemMsid(msid);
|
const isSystem = isSystemMsid(msid);
|
||||||
|
|
||||||
const isNewMessageSoundEnabled = (clientInstance) => {
|
const isNewMessageSoundEnabled = (clientInstance) => {
|
||||||
@@ -230,9 +240,13 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
|
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
|
||||||
playNewMessageSound(bodyshop.id);
|
playNewMessageSound(bodyshop.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real-time badge update for affix (best-effort, coalesced)
|
||||||
|
if (!isSystem) {
|
||||||
|
refetchUnreadCount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we think it's "new", sanity-check the cache: if conversation exists, treat as existing.
|
|
||||||
if (!existingConversation && conversationId) {
|
if (!existingConversation && conversationId) {
|
||||||
try {
|
try {
|
||||||
const cachedConversation = client.cache.readFragment({
|
const cachedConversation = client.cache.readFragment({
|
||||||
@@ -253,7 +267,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New conversation: upsert into offset-0 conversation list cache
|
|
||||||
if (!existingConversation && newConversation?.phone_num) {
|
if (!existingConversation && newConversation?.phone_num) {
|
||||||
try {
|
try {
|
||||||
upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem });
|
upsertConversationIntoOffsetZeroList(client, newConversation, { isoutbound, isSystem });
|
||||||
@@ -263,7 +276,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing conversation: update updated_at and unread badge (only for inbound non-system)
|
|
||||||
if (existingConversation && conversationId) {
|
if (existingConversation && conversationId) {
|
||||||
try {
|
try {
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
@@ -272,7 +284,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
updated_at: () => updated_at || safeIsoNow(),
|
updated_at: () => updated_at || safeIsoNow(),
|
||||||
archived: () => false,
|
archived: () => false,
|
||||||
|
|
||||||
// Badge in your list uses messages_aggregate.aggregate.count with is_system excluded
|
|
||||||
messages_aggregate(cached = null) {
|
messages_aggregate(cached = null) {
|
||||||
if (isoutbound || isSystem) return cached;
|
if (isoutbound || isSystem) return cached;
|
||||||
|
|
||||||
@@ -286,7 +297,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Keep legacy unreadcnt reasonably in sync (if you still display it anywhere)
|
|
||||||
unreadcnt(cached) {
|
unreadcnt(cached) {
|
||||||
if (isoutbound || isSystem) return cached;
|
if (isoutbound || isSystem) return cached;
|
||||||
const n = typeof cached === "number" ? cached : 0;
|
const n = typeof cached === "number" ? cached : 0;
|
||||||
@@ -294,12 +304,9 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional: bubble to top of offset-0 list by rewriting entity is enough
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating cache for existing conversation:", error);
|
console.error("Error updating cache for existing conversation:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +318,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
|
|
||||||
logLocal("handleNewMessageDetailed - Start", message);
|
logLocal("handleNewMessageDetailed - Start", message);
|
||||||
|
|
||||||
// If the conversation thread isn't open/cached, don't try to append messages
|
|
||||||
if (!conversationId || !isConversationDetailsCached(client, conversationId)) return;
|
if (!conversationId || !isConversationDetailsCached(client, conversationId)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -326,7 +332,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the entity (no missing-field warnings because normalized includes is_system)
|
|
||||||
client.cache.writeFragment({
|
client.cache.writeFragment({
|
||||||
id: messageCacheId,
|
id: messageCacheId,
|
||||||
fragment: gql`
|
fragment: gql`
|
||||||
@@ -347,7 +352,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
data: normalized
|
data: normalized
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append a ref (not a raw object) and avoid duplicates
|
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||||
fields: {
|
fields: {
|
||||||
@@ -357,7 +361,7 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
return [...existing, { __ref: messageCacheId }];
|
return [...existing, { __ref: messageCacheId }];
|
||||||
},
|
},
|
||||||
updated_at() {
|
updated_at() {
|
||||||
return normalized.created_at || new Date().toISOString();
|
return normalized.created_at || safeIsoNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -373,7 +377,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
|
|
||||||
logLocal("handleMessageChanged - Start", message);
|
logLocal("handleMessageChanged - Start", message);
|
||||||
|
|
||||||
// Only update if the message entity exists locally
|
|
||||||
if (!messageEntityCached(client, message.id)) return;
|
if (!messageEntityCached(client, message.id)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -422,7 +425,8 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "conversation-marked-read": {
|
case "conversation-marked-read": {
|
||||||
// Update message entities only if details are cached, otherwise just update counters.
|
refetchUnreadCount();
|
||||||
|
|
||||||
if (detailsCached && Array.isArray(messageIds)) {
|
if (detailsCached && Array.isArray(messageIds)) {
|
||||||
messageIds.forEach((id) => {
|
messageIds.forEach((id) => {
|
||||||
if (!messageEntityCached(client, id)) return;
|
if (!messageEntityCached(client, id)) return;
|
||||||
@@ -449,6 +453,8 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "conversation-marked-unread": {
|
case "conversation-marked-unread": {
|
||||||
|
refetchUnreadCount();
|
||||||
|
|
||||||
const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1;
|
const safeUnreadCount = typeof unreadCount === "number" ? unreadCount : 1;
|
||||||
const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : [];
|
const idsMarkedRead = Array.isArray(messageIdsMarkedRead) ? messageIdsMarkedRead : [];
|
||||||
|
|
||||||
@@ -488,6 +494,9 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "conversation-created": {
|
case "conversation-created": {
|
||||||
|
// New conversation likely implies new unread inbound message(s)
|
||||||
|
refetchUnreadCount();
|
||||||
|
|
||||||
const conv = enrichConversation(
|
const conv = enrichConversation(
|
||||||
{ id: conversationId, job_conversations, ...fields, updated_at: updatedAt },
|
{ id: conversationId, job_conversations, ...fields, updated_at: updatedAt },
|
||||||
{ isoutbound: false, isSystem: false }
|
{ isoutbound: false, isSystem: false }
|
||||||
@@ -498,7 +507,9 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
|
|
||||||
case "conversation-unarchived":
|
case "conversation-unarchived":
|
||||||
case "conversation-archived": {
|
case "conversation-archived": {
|
||||||
// Correct refetch usage: this refetches any ACTIVE watchers for these documents.
|
// Keep unread badge correct even if archiving affects counts
|
||||||
|
refetchUnreadCount();
|
||||||
|
|
||||||
await client.refetchQueries({
|
await client.refetchQueries({
|
||||||
include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS]
|
include: [CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS]
|
||||||
});
|
});
|
||||||
@@ -546,7 +557,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser, bodysho
|
|||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// Safe partial updates to the conversation entity
|
|
||||||
client.cache.modify({
|
client.cache.modify({
|
||||||
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
|
||||||
fields: Object.fromEntries(
|
fields: Object.fromEntries(
|
||||||
|
|||||||
Reference in New Issue
Block a user