mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat: add support to freight portal
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
|||||||
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||||
|
import SupportWidget from "@/features/support/SupportWidget";
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -781,6 +782,9 @@ export function AppLayout({
|
|||||||
{children}
|
{children}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{/* Floating customer-support chat launcher. */}
|
||||||
|
<SupportWidget />
|
||||||
|
|
||||||
{/* Create-profile modal — opens when switching to a mode the company
|
{/* Create-profile modal — opens when switching to a mode the company
|
||||||
doesn't have a profile for yet. */}
|
doesn't have a profile for yet. */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Headset, Send, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useConversation,
|
||||||
|
useMarkConversationRead,
|
||||||
|
useMessages,
|
||||||
|
useSendMessage,
|
||||||
|
} from "./useSupport";
|
||||||
|
|
||||||
|
function formatTime(iso?: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
return sameDay
|
||||||
|
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole support surface: one ongoing thread with the EDR team. There is
|
||||||
|
* nothing to pick and nothing to open — the company has a single conversation,
|
||||||
|
* created server-side the moment the first message is sent.
|
||||||
|
*/
|
||||||
|
export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||||
|
const { data: conversation } = useConversation();
|
||||||
|
const { data: messages, isLoading } = useMessages();
|
||||||
|
const send = useSendMessage();
|
||||||
|
const markRead = useMarkConversationRead();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Mark read on open + whenever new messages arrive. No-op before the thread
|
||||||
|
// exists, so opening the panel never creates one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversation) markRead.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conversation?.id, messages?.length]);
|
||||||
|
|
||||||
|
// Auto-scroll to newest.
|
||||||
|
useEffect(() => {
|
||||||
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||||
|
}, [messages?.length]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setDraft("");
|
||||||
|
await send.mutateAsync(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = !isLoading && (messages ?? []).length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
shadow="xl"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: 384,
|
||||||
|
height: 560,
|
||||||
|
maxHeight: "calc(100vh - 120px)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-edr-green-7), var(--mantine-color-edr-green-5))",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="white" radius="xl" size="lg" color="edr-green">
|
||||||
|
<Headset size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} size="sm" truncate>
|
||||||
|
Support
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" opacity={0.85} truncate>
|
||||||
|
We usually reply within a few minutes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon variant="transparent" color="white" onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<Stack align="center" gap="xs" px="lg" py={48} c="dimmed">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size={48}>
|
||||||
|
<Headset size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" ta="center">
|
||||||
|
Send us a message and our team will help you out.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm" p="md">
|
||||||
|
{(messages ?? []).map((m) => (
|
||||||
|
<MessageBubble key={m.id} m={m} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||||
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
|
placeholder="Type a message…"
|
||||||
|
autosize
|
||||||
|
minRows={1}
|
||||||
|
maxRows={4}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
size={38}
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
loading={send.isPending}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ m }: { m: SupportMessageDto }) {
|
||||||
|
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify={mine ? "flex-end" : "flex-start"}
|
||||||
|
wrap="nowrap"
|
||||||
|
align="flex-end"
|
||||||
|
gap="xs"
|
||||||
|
>
|
||||||
|
{!mine && (
|
||||||
|
<Avatar size="sm" radius="xl" color="edr-green" variant="filled">
|
||||||
|
<Headset size={14} />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Box style={{ maxWidth: "78%" }}>
|
||||||
|
{!mine && (
|
||||||
|
<Text size="xs" c="dimmed" mb={2} ml={4}>
|
||||||
|
{m.authorName || "Support agent"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Paper
|
||||||
|
px="sm"
|
||||||
|
py={8}
|
||||||
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
background: mine
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "var(--mantine-color-gray-1)",
|
||||||
|
color: mine ? "white" : "var(--mantine-color-dark-7)",
|
||||||
|
borderBottomRightRadius: mine ? 4 : undefined,
|
||||||
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{m.body}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||||
|
{formatTime(m.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupportPanel;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Affix, Indicator, Transition } from "@mantine/core";
|
||||||
|
import { Headset } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
import { SupportPanel } from "./SupportPanel";
|
||||||
|
import { useSupportUnreadCount } from "./useSupport";
|
||||||
|
import { useSupportSocket } from "./useSupportSocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating customer-support launcher, mounted in the authenticated app shell.
|
||||||
|
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
||||||
|
* fresh and toast the customer when the panel is closed.
|
||||||
|
*/
|
||||||
|
export function SupportWidget() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data: unread = 0 } = useSupportUnreadCount(isAuthenticated);
|
||||||
|
|
||||||
|
useSupportSocket(isAuthenticated, (event) => {
|
||||||
|
// Only the customer-visible side matters here; agent replies arrive as AGENT.
|
||||||
|
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
|
||||||
|
toast(
|
||||||
|
`Support replied: ${event.message.body.slice(0, 60)}${
|
||||||
|
event.message.body.length > 60 ? "…" : ""
|
||||||
|
}`,
|
||||||
|
{ icon: "💬" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Affix position={{ bottom: 24, right: 24 }} zIndex={300}>
|
||||||
|
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles} className="mb-3">
|
||||||
|
<SupportPanel onClose={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition mounted={!open} transition="pop" duration={150}>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles} className="flex justify-end">
|
||||||
|
<Indicator
|
||||||
|
label={unread > 9 ? "9+" : unread}
|
||||||
|
size={20}
|
||||||
|
offset={8}
|
||||||
|
color="red"
|
||||||
|
disabled={unread === 0}
|
||||||
|
processing
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open support chat"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||||
|
>
|
||||||
|
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
|
||||||
|
{unread === 0 && (
|
||||||
|
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||||
|
<Headset size={22} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="relative hidden text-left leading-tight sm:block">
|
||||||
|
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||||
|
👋 Need help?
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||||
|
Chat with our team
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Indicator>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Affix>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupportWidget;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
SendSupportMessageResult,
|
||||||
|
SupportConversationDto,
|
||||||
|
SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
import { client } from "@/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal support-chat REST calls. My company has exactly one thread, so nothing
|
||||||
|
* here takes a conversation id — the server derives it from the caller.
|
||||||
|
*
|
||||||
|
* The portal axios `client` returns the raw response and the API wraps payloads
|
||||||
|
* in a `{ success, data }` envelope, so we unwrap `.data.data` (same convention
|
||||||
|
* as the other portal services).
|
||||||
|
*/
|
||||||
|
export const supportApi = {
|
||||||
|
/** Null until someone has actually sent a message. */
|
||||||
|
getConversation: async (): Promise<SupportConversationDto | null> => {
|
||||||
|
const { data } = await client.get("/api/support/conversation");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
listMessages: async (): Promise<SupportMessageDto[]> => {
|
||||||
|
const { data } = await client.get("/api/support/conversation/messages");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
|
||||||
|
const { data } = await client.post("/api/support/conversation/messages", {
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
markRead: async (): Promise<{ unreadCount: number }> => {
|
||||||
|
const { data } = await client.post("/api/support/conversation/read");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await client.get("/api/support/unread-count");
|
||||||
|
return data.data.unreadCount;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { SupportConversationDto } from "@edr/types";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { supportApi } from "./supportApi";
|
||||||
|
|
||||||
|
export const SUPPORT_KEY = ["support"] as const;
|
||||||
|
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
|
||||||
|
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
|
||||||
|
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
|
||||||
|
|
||||||
|
/** My company's support thread — null until the first message is sent. */
|
||||||
|
export function useConversation(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_CONVERSATION_KEY,
|
||||||
|
queryFn: () => supportApi.getConversation(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The thread's messages, oldest first. Empty until the thread exists. */
|
||||||
|
export function useMessages(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_MESSAGES_KEY,
|
||||||
|
queryFn: () => supportApi.listMessages(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupportUnreadCount(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_UNREAD_KEY,
|
||||||
|
queryFn: () => supportApi.unreadCount(),
|
||||||
|
enabled,
|
||||||
|
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send as the customer. Opens the thread server-side if this is the first one. */
|
||||||
|
export function useSendMessage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: string) => supportApi.sendMessage(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkConversationRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => supportApi.markRead(),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SupportConversationDto };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
SUPPORT_CHAT_WS_EVENTS,
|
||||||
|
SUPPORT_CHAT_WS_NAMESPACE,
|
||||||
|
type SupportConversationDto,
|
||||||
|
type SupportMessageEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SUPPORT_CONVERSATION_KEY,
|
||||||
|
SUPPORT_MESSAGES_KEY,
|
||||||
|
SUPPORT_UNREAD_KEY,
|
||||||
|
} from "./useSupport";
|
||||||
|
|
||||||
|
function getAuthToken(): string | undefined {
|
||||||
|
return document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("auth-token="))
|
||||||
|
?.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live support-chat pushes for the signed-in customer. The company
|
||||||
|
* only has one thread, so any push refreshes it wholesale — messages, the
|
||||||
|
* conversation itself, and the unread badge — and fires `onMessage` (the widget
|
||||||
|
* shows a toast when the panel is closed).
|
||||||
|
*/
|
||||||
|
export function useSupportSocket(
|
||||||
|
enabled: boolean,
|
||||||
|
onMessage?: (event: SupportMessageEvent) => void,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
onMessageRef.current?.(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||||
|
(_conversation: SupportConversationDto) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user