212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef } from "react"
|
|
import { toast } from "sonner"
|
|
|
|
import { createAdminWebSocketUrl } from "@/lib/api/admin"
|
|
import { type AgentMessage } from "@/lib/api/agent"
|
|
import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime"
|
|
import { readSession } from "@/lib/auth"
|
|
import {
|
|
normalizeRealtimeMessage,
|
|
type RealtimeConversationPatch,
|
|
type RealtimeMessageCreatedPayload,
|
|
} from "@/lib/im-realtime-state"
|
|
import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
|
|
import { getNotificationBody, showNotification } from "@/lib/services/notification"
|
|
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
|
|
import { useI18n } from "@/i18n/provider"
|
|
|
|
type AgentRealtimeConnection = ReturnType<typeof createRealtimeConnectionManager>
|
|
type AgentRealtimeEnvelope = {
|
|
eventId?: string
|
|
type?: string
|
|
data?: RealtimeMessageCreatedPayload<AgentMessage> &
|
|
RealtimeConversationPatch & {
|
|
messageId?: number
|
|
recalledAt?: string
|
|
sendStatus?: number
|
|
}
|
|
}
|
|
|
|
export function useAgentConversationRealtime() {
|
|
const t = useI18n()
|
|
const selectedConversationId = useAgentConversationsStore(
|
|
(state) => state.selectedConversationId
|
|
)
|
|
const setRealtimeStatus = useAgentConversationsStore(
|
|
(state) => state.setRealtimeStatus
|
|
)
|
|
const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
|
|
const subscribedConversationIdRef = useRef<number | null>(null)
|
|
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
|
|
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
|
|
|
|
useEffect(() => {
|
|
selectedConversationIdRef.current = selectedConversationId
|
|
}, [selectedConversationId])
|
|
|
|
useEffect(() => {
|
|
const realtime = createRealtimeConnectionManager({
|
|
createSocket: () => new WebSocket(createAdminWebSocketUrl()),
|
|
onStatusChange: setRealtimeStatus,
|
|
onOpen: (socket) => {
|
|
console.info("[agent-realtime] websocket connected", {
|
|
url: socket.url,
|
|
})
|
|
|
|
const conversationId = selectedConversationIdRef.current
|
|
if (conversationId) {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "subscribe",
|
|
topics: [`conversation:${conversationId}`],
|
|
})
|
|
)
|
|
subscribedConversationIdRef.current = conversationId
|
|
} else {
|
|
subscribedConversationIdRef.current = null
|
|
}
|
|
},
|
|
onMessage: (event, socket) => {
|
|
try {
|
|
const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope
|
|
const eventType = envelope.type ?? ""
|
|
const payload = envelope.data
|
|
const conversationId = payload?.conversationId ?? 0
|
|
const eventId = envelope.eventId?.trim() ?? ""
|
|
|
|
if (
|
|
eventType === "" ||
|
|
eventType === "connected" ||
|
|
eventType === "pong" ||
|
|
eventType === "subscribed" ||
|
|
eventType === "unsubscribed"
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (eventId && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: "ack", eventId }))
|
|
}
|
|
|
|
const store = useAgentConversationsStore.getState()
|
|
if (eventType === "resyncRequired") {
|
|
void store.resyncRealtimeData(conversationId).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : t("conversation.syncConversationDataFailed"))
|
|
})
|
|
return
|
|
}
|
|
|
|
if (eventType === "message.created") {
|
|
const message = normalizeRealtimeMessage<AgentMessage>(payload)
|
|
if (!message) {
|
|
void store.resyncRealtimeData(conversationId).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : t("conversation.syncMessagesFailed"))
|
|
})
|
|
return
|
|
}
|
|
store.applyRealtimeMessageCreated(message)
|
|
|
|
const shouldNotify =
|
|
message.senderType === "customer" &&
|
|
payload?.status === 2 &&
|
|
(payload.currentAssigneeId ?? 0) > 0 &&
|
|
payload.currentAssigneeId === currentUserIdRef.current &&
|
|
typeof document !== "undefined" &&
|
|
document.visibilityState !== "visible"
|
|
|
|
if (shouldNotify) {
|
|
showNotification(t("conversation.newMessage"), getNotificationBody(message), () => {
|
|
void store.selectConversation(message.conversationId)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (eventType === "message.recalled" && payload?.messageId) {
|
|
store.applyRealtimeMessageRecalled(payload.messageId, {
|
|
sendStatus: payload.sendStatus,
|
|
recalledAt: payload.recalledAt,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (eventType.startsWith("conversation.") && payload) {
|
|
store.applyRealtimeConversationChanged(payload)
|
|
if (shouldReloadConversationListForRealtimePatch(payload)) {
|
|
void store.resyncRealtimeData(conversationId).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : t("conversation.syncConversationListFailed"))
|
|
})
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore invalid ws payload
|
|
}
|
|
},
|
|
onClose: (event, socket) => {
|
|
console.log("[agent-realtime] websocket closed", {
|
|
url: socket.url,
|
|
readyState: socket.readyState,
|
|
code: event.code,
|
|
reason: event.reason,
|
|
wasClean: event.wasClean,
|
|
})
|
|
subscribedConversationIdRef.current = null
|
|
},
|
|
onError: (_event, socket) => {
|
|
console.log("[agent-realtime] websocket error", {
|
|
url: socket.url,
|
|
readyState: socket.readyState,
|
|
})
|
|
},
|
|
onConnectError: (error) => {
|
|
toast.error(error instanceof Error ? error.message : t("conversation.realtimeConnectFailed"))
|
|
},
|
|
})
|
|
|
|
realtimeRef.current = realtime
|
|
realtime.connect()
|
|
|
|
return () => {
|
|
realtimeRef.current = null
|
|
realtime.disconnect()
|
|
subscribedConversationIdRef.current = null
|
|
}
|
|
}, [setRealtimeStatus, t])
|
|
|
|
useEffect(() => {
|
|
const socket = realtimeRef.current?.getSocket()
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
return
|
|
}
|
|
|
|
const previousConversationId = subscribedConversationIdRef.current
|
|
const nextConversationId = selectedConversationId ?? null
|
|
|
|
if (previousConversationId && previousConversationId !== nextConversationId) {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "unsubscribe",
|
|
topics: [`conversation:${previousConversationId}`],
|
|
})
|
|
)
|
|
}
|
|
|
|
if (nextConversationId && nextConversationId !== previousConversationId) {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "subscribe",
|
|
topics: [`conversation:${nextConversationId}`],
|
|
})
|
|
)
|
|
subscribedConversationIdRef.current = nextConversationId
|
|
return
|
|
}
|
|
|
|
if (!nextConversationId) {
|
|
subscribedConversationIdRef.current = null
|
|
}
|
|
}, [selectedConversationId])
|
|
}
|