Files
agent-desk-ng/web/lib/stores/support-chat.ts

1008 lines
29 KiB
TypeScript

"use client"
import { create } from "zustand"
import {
closeImConversation,
createImConversation,
createOrMatchImConversation,
ensureCustomerSession,
fetchImConversationList,
fetchImMessages,
fetchImWidgetConfig,
markImMessageRead,
sendImMessage,
uploadImAttachment,
uploadImImage,
applyCustomerSessionRefresh,
type ConversationSatisfactionResponse,
type ImAsset,
type ImConversation,
type ImMessage,
type ImWidgetConfig,
} from "@/lib/api/im"
import {
createImRealtimeConnection,
type ImRealtimeEnvelope,
} from "@/lib/im-realtime"
import {
resolveAgentActivityState,
type AgentActivityInfo,
} from "@/lib/agent-activity-state"
import {
cursorFromLoadedImMessages,
hasMoreAfterLatestImMessageMerge,
mergeImMessagesByIdAsc,
parseImMessageCursorId,
} from "@/lib/im-message-merge"
import {
markMessagesReadToMessageId,
normalizeRealtimeMessage,
patchConversationList,
patchConversationListWithMessage,
} from "@/lib/im-realtime-state"
import {
CLOSED_IM_CONVERSATION_STATUS,
findConversationById,
pickConversationToResume,
sortConversationsByRecency,
} from "@/lib/support-conversation-list"
import { summarizeIMMessage } from "@/lib/im-message"
import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
import { generateUUID } from "@/lib/utils"
import {
readSupportChatRuntimeConfig,
setSupportChatRuntimeConfig,
} from "@/lib/sdk/runtime-config"
import { translateCurrentMessage } from "@/i18n/messages"
type ChatStatus = "connecting" | "connected" | "disconnected"
const DEFAULT_PAGE_LIMIT = 50
const CONVERSATION_LIST_PAGE_LIMIT = 50
/**
* The single writer for the request list. `conversation` is never assigned
* anywhere else: it is always re-derived from `conversations` by id, so the
* active request and the list cannot drift apart and leave the composer
* pointed at a request the switcher does not show.
*/
function withConversations(
state: Pick<SupportChatStore, "conversations" | "activeConversationId">,
conversations: ImConversation[],
activeConversationId?: number
) {
const sorted = sortConversationsByRecency(conversations)
const resolvedId =
findConversationById(sorted, activeConversationId ?? state.activeConversationId)
?.id ?? 0
return {
conversations: sorted,
activeConversationId: resolvedId,
conversation: findConversationById(sorted, resolvedId),
}
}
/**
* Applies a field change to the request on screen by routing it through the
* list, so the switcher badge and the header never disagree about the same
* request.
*/
function withActiveConversationPatch(
state: Pick<SupportChatStore, "conversations" | "activeConversationId">,
patch: Partial<ImConversation>
) {
const active = findConversationById(
state.conversations,
state.activeConversationId
)
if (!active) {
return {}
}
return withConversations(
state,
patchConversationList(state.conversations, { ...patch, id: active.id })
)
}
function getNotificationBody(message: ImMessage): string {
return summarizeIMMessage(message)
}
function showNotification(title: string, body: string, onClick?: () => void) {
if (typeof window === "undefined" || !("Notification" in window)) {
return
}
const create = () => {
const notification = new Notification(title, { body })
notification.onclick = () => {
window.focus()
onClick?.()
notification.close()
}
}
if (Notification.permission === "granted") {
create()
return
}
if (Notification.permission === "default") {
void Notification.requestPermission().then((permission) => {
if (permission === "granted") {
create()
}
})
}
}
function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] {
return Array.isArray(value) ? value : []
}
function markConversationReadMessages(
messages: ImMessage[],
payload: ImRealtimeEnvelope["data"] | ImRealtimeEnvelope["payload"]
) {
let next = messages
if ((payload?.agentLastReadMessageId ?? 0) > 0) {
next = markMessagesReadToMessageId(
next,
payload?.agentLastReadMessageId ?? 0,
"agent",
payload?.agentLastReadAt
)
}
if ((payload?.customerLastReadMessageId ?? 0) > 0) {
next = markMessagesReadToMessageId(
next,
payload?.customerLastReadMessageId ?? 0,
"customer",
payload?.customerLastReadAt
)
}
return next
}
export type SupportChatStore = {
title: string
subtitle: string
themeColor: string
promptStarters: string[]
/** Every request this visitor owns, newest activity first. */
conversations: ImConversation[]
/** The request on screen. Derived from conversations by activeConversationId. */
conversation: ImConversation | null
activeConversationId: number
activeAgentActivity: AgentActivityInfo | null
conversationsLoading: boolean
creatingConversation: boolean
messages: ImMessage[]
messagesCursor: string
messagesHasMore: boolean
messagesLoadingMore: boolean
initialized: boolean
status: ChatStatus
error: string
sending: boolean
uploadingAsset: boolean
closingConversation: boolean
isOpen: boolean
isVisible: boolean
socket: WebSocket | null
readingMessageId: number
setIsOpen: (isOpen: boolean) => void
setIsVisible: (isVisible: boolean) => void
bootstrap: () => void
disconnectSocket: () => void
refreshConversations: () => Promise<void>
selectConversation: (conversationId: number) => Promise<void>
startNewConversation: (subject?: string) => Promise<void>
refreshMessages: () => Promise<void>
syncLatestMessages: () => Promise<void>
loadOlderMessages: () => Promise<void>
markConversationRead: () => Promise<void>
handleSendMessage: (content: string) => Promise<void>
sendMessage: (content: string) => Promise<void>
uploadMessageImage: (file: File) => Promise<ImAsset | null>
sendAttachment: (file: File) => Promise<void>
closeConversation: () => Promise<void>
setConversationSatisfaction: (satisfaction: ConversationSatisfactionResponse) => void
retry: () => Promise<void>
}
let bootstrapToken = 0
function t(key: string) {
return translateCurrentMessage(key)
}
function buildSelectConversationPatch(
state: {
conversations: ImConversation[]
activeConversationId: number
},
conversationId: number
) {
const conversation = findConversationById(state.conversations, conversationId)
if (!conversation) {
return null
}
return {
conversations: state.conversations,
activeConversationId: conversation.id,
conversation,
messages: [] as ImMessage[],
messagesCursor: "",
messagesHasMore: false,
messagesLoadingMore: false,
readingMessageId: 0,
}
}
export const useSupportChatStore = create<SupportChatStore>((set, get) => {
let subscribedConversationId: number | null = null
const sendSubscription = (
socket: WebSocket | null,
prevId: number | null,
nextId: number | null
) => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return
}
if (prevId && prevId !== nextId) {
socket.send(
JSON.stringify({
type: "unsubscribe",
topics: [`conversation:${prevId}`],
})
)
}
if (nextId && nextId > 0) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${nextId}`],
})
)
}
}
const scheduleMessageReplyCheck = (
conversationId: number,
sentMessageId: number
) => {
for (const delay of [1500, 3500, 7000]) {
setTimeout(() => {
const current = get()
if (current.conversation?.id === conversationId) {
if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) {
connectSocket()
}
const hasNewer = current.messages.some((m) => m.id > sentMessageId)
if (!hasNewer) {
void current.syncLatestMessages()
}
}
}, delay)
}
}
const realtime = createRealtimeConnectionManager({
beforeConnect: () => ensureCustomerSession(),
createSocket: createImRealtimeConnection,
canReconnect: () => Boolean(get().isOpen && get().conversation?.id),
onStatusChange: (status) => {
if (get().isOpen || status === "disconnected") {
set({ status })
}
},
onSocketChange: (socket) => {
set({ socket })
},
onOpen: (socket) => {
const activeConversationId =
get().conversation?.id || get().activeConversationId
if (activeConversationId > 0) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${activeConversationId}`],
})
)
subscribedConversationId = activeConversationId
void get().syncLatestMessages()
} else {
subscribedConversationId = null
}
},
onClose: () => {
subscribedConversationId = null
},
onMessage: (messageEvent) => {
let event: ImRealtimeEnvelope
try {
event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope
} catch {
return
}
const payload = event.data ?? event.payload
if (event.type === "customer_session.refresh") {
applyCustomerSessionRefresh(payload)
return
}
const activeConversationId = get().conversation?.id
if (!activeConversationId) {
return
}
if (event.type === "resyncRequired") {
void get().refreshMessages()
return
}
const eventConversationId = Number(payload?.conversationId) || 0
if (!eventConversationId) {
return
}
if (event.type === "agent.activity") {
set({
activeAgentActivity: resolveAgentActivityState(
get().activeAgentActivity,
activeConversationId,
payload
),
})
return
}
if (event.type === "message.created") {
const isActive = eventConversationId === activeConversationId
const message = normalizeRealtimeMessage<ImMessage>(payload)
if (!message) {
if (isActive) {
set({ activeAgentActivity: null })
void get().syncLatestMessages()
}
return
}
set((state) => {
const conversations = patchConversationListWithMessage(
state.conversations,
message
)
if (!isActive) {
// Activity in a background request still has to show up in the
// switcher, otherwise the visitor only finds out by opening each
// request in turn.
return withConversations(state, conversations)
}
return {
...withConversations(state, conversations),
messages: mergeImMessagesByIdAsc(state.messages, [message]),
activeAgentActivity: null,
}
})
if (
isActive &&
message.senderType !== "customer" &&
typeof document !== "undefined" &&
document.visibilityState !== "visible"
) {
const state = get()
showNotification(t("supportChat.newMessage"), getNotificationBody(message), () => {
state.setIsOpen(true)
state.setIsVisible(true)
})
}
return
}
if (event.type?.startsWith("conversation.")) {
set((state) =>
withConversations(
state,
patchConversationList(state.conversations, payload)
)
)
if (eventConversationId !== activeConversationId) {
return
}
set((state) => ({
messages: markConversationReadMessages(state.messages, payload),
...(event.type === "conversation.closed" ||
payload?.status === CLOSED_IM_CONVERSATION_STATUS
? { activeAgentActivity: null }
: {}),
}))
}
},
})
const closeSocket = (options?: { reconnect?: boolean }) => {
subscribedConversationId = null
realtime.disconnect({
reconnect: options?.reconnect ?? false,
updateStatus: true,
})
}
const connectSocket = () => {
const activeConversationId =
get().conversation?.id || get().activeConversationId
if (!activeConversationId) {
return
}
const socket = get().socket
if (socket && socket.readyState === WebSocket.OPEN) {
if (activeConversationId !== subscribedConversationId) {
sendSubscription(socket, subscribedConversationId, activeConversationId)
subscribedConversationId = activeConversationId
}
if (get().status !== "connected") {
set({ status: "connected" })
}
return
}
if (socket && socket.readyState === WebSocket.CONNECTING) {
return
}
void realtime.connect()
}
return {
title: t("supportChat.title"),
subtitle: "",
themeColor: "#2563eb",
promptStarters: [],
conversations: [],
conversation: null,
activeConversationId: 0,
activeAgentActivity: null,
conversationsLoading: false,
creatingConversation: false,
messages: [],
messagesCursor: "",
messagesHasMore: false,
messagesLoadingMore: false,
initialized: false,
status: "connecting",
error: "",
sending: false,
uploadingAsset: false,
closingConversation: false,
isOpen: typeof window !== "undefined" ? window.self === window.top : false,
isVisible:
typeof window !== "undefined" ? window.self === window.top : false,
socket: null,
readingMessageId: 0,
setIsOpen: (isOpen: boolean) => {
set({ isOpen })
},
setIsVisible: (isVisible: boolean) => {
set({ isVisible })
},
bootstrap: () => {
const token = ++bootstrapToken
if (!get().isOpen) {
closeSocket({ reconnect: false })
set({ status: "disconnected" })
return
}
const activateChat = async () => {
try {
set({ error: "", status: "connecting" })
const widgetConfig: ImWidgetConfig = await fetchImWidgetConfig().catch(
() => ({})
)
if (bootstrapToken !== token || !get().isOpen) {
return
}
if (widgetConfig.channelId) {
setSupportChatRuntimeConfig({
...readSupportChatRuntimeConfig(),
channelId:
widgetConfig.channelId || readSupportChatRuntimeConfig().channelId,
})
}
set({
title: widgetConfig.title || t("supportChat.title"),
subtitle: widgetConfig.subtitle || "",
themeColor: widgetConfig.themeColor || "#2563eb",
promptStarters: widgetConfig.promptStarters || [],
})
await ensureCustomerSession()
if (bootstrapToken !== token || !get().isOpen) {
return
}
// Rejoin the newest still-open request rather than blindly creating
// one: with several requests in play, create_or_match would silently
// drop the visitor back into their first thread.
let conversations: ImConversation[] = []
try {
const page = await fetchImConversationList({
limit: CONVERSATION_LIST_PAGE_LIMIT,
})
conversations = Array.isArray(page?.results) ? page.results : []
} catch {
// A visitor who has never written to us has no request rows yet,
// and the list endpoint may legitimately refuse on a cold channel.
// Falling through to create_or_match keeps a first-time visitor
// working, which matters more here than surfacing the failure.
}
if (bootstrapToken !== token || !get().isOpen) {
return
}
const resumable = pickConversationToResume(conversations)
if (resumable) {
set((state) => ({
initialized: true,
...withConversations(state, conversations, resumable.id),
}))
} else {
// Either a brand new visitor, or every request is closed. Both need
// a fresh thread; create_or_match is the only path that can tell.
const created = await createOrMatchImConversation()
if (bootstrapToken !== token || !get().isOpen) {
return
}
set((state) => ({
initialized: true,
...withConversations(
state,
[
created,
...conversations.filter((item) => item.id !== created.id),
],
created.id
),
}))
}
await get().refreshMessages()
if (bootstrapToken !== token || !get().isOpen) {
return
}
connectSocket()
} catch (error) {
if (bootstrapToken !== token || !get().isOpen) {
return
}
set({
status: "disconnected",
error: error instanceof Error ? error.message : t("supportChat.initFailed"),
})
}
}
void activateChat()
},
disconnectSocket: () => {
closeSocket({ reconnect: false })
},
refreshConversations: async () => {
try {
const page = await fetchImConversationList({
limit: CONVERSATION_LIST_PAGE_LIMIT,
})
const results = Array.isArray(page?.results) ? page.results : []
set((state) => withConversations(state, results))
} catch (error) {
set({
error:
error instanceof Error
? error.message
: t("supportChat.loadConversationsFailed"),
})
}
},
selectConversation: async (conversationId: number) => {
const prevId = get().conversation?.id || get().activeConversationId
const patch = buildSelectConversationPatch(get(), conversationId)
if (!patch) {
return
}
// The patch clears messages and re-derives `conversation` together with
// the active id; otherwise refreshMessages would still read the previous
// request and show its history under the new header.
set({
...patch,
activeAgentActivity: null,
})
const socket = get().socket
if (socket && socket.readyState === WebSocket.OPEN) {
sendSubscription(socket, prevId, conversationId)
subscribedConversationId = conversationId
} else if (get().isOpen) {
connectSocket()
}
try {
await get().refreshMessages()
} catch (error) {
console.error("Failed to load support chat messages", error)
}
},
startNewConversation: async (subject?: string) => {
if (get().creatingConversation) {
return
}
set({ creatingConversation: true, error: "", activeAgentActivity: null })
try {
const prevId = get().conversation?.id || get().activeConversationId
const created = await createImConversation(subject)
set((state) => ({
creatingConversation: false,
messages: [],
messagesCursor: "",
messagesHasMore: false,
activeAgentActivity: null,
...withConversations(
state,
[created, ...state.conversations.filter((item) => item.id !== created.id)],
created.id
),
}))
const socket = get().socket
if (socket && socket.readyState === WebSocket.OPEN) {
sendSubscription(socket, prevId, created.id)
subscribedConversationId = created.id
} else if (get().isOpen) {
connectSocket()
}
await get().refreshMessages()
const latestMsgId = get().messages.at(-1)?.id ?? 0
scheduleMessageReplyCheck(created.id, latestMsgId)
} catch (error) {
set({
creatingConversation: false,
error:
error instanceof Error
? error.message
: t("supportChat.createConversationFailed"),
})
throw error
}
},
refreshMessages: async () => {
const conversationId = get().conversation?.id
if (!conversationId) {
return
}
try {
const page = await fetchImMessages({
conversationId,
limit: DEFAULT_PAGE_LIMIT,
})
if (get().conversation?.id !== conversationId) {
return
}
const results = ensureMessageList(page.results)
set({
messages: results,
messagesCursor: cursorFromLoadedImMessages(results) || page.cursor || "",
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
})
} catch (error) {
if (get().conversation?.id !== conversationId) {
return
}
set({
error: error instanceof Error ? error.message : t("supportChat.loadMessagesFailed"),
})
throw error
}
},
syncLatestMessages: async () => {
const conversationId = get().conversation?.id
if (!conversationId) {
return
}
try {
const page = await fetchImMessages({
conversationId,
limit: DEFAULT_PAGE_LIMIT,
})
const batch = ensureMessageList(page.results)
if (batch.length === 0) {
return
}
set((state) => {
const merged = mergeImMessagesByIdAsc(state.messages, batch)
return {
messages: merged,
messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "",
messagesHasMore: hasMoreAfterLatestImMessageMerge({
previousMessages: state.messages,
previousHasMore: state.messagesHasMore,
merged,
apiHasMore: Boolean(page.hasMore) || batch.length >= DEFAULT_PAGE_LIMIT,
}),
}
})
} catch (error) {
set({
error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"),
})
}
},
loadOlderMessages: async () => {
const conversationId = get().conversation?.id
if (
!conversationId ||
get().messagesLoadingMore ||
!get().messagesHasMore
) {
return
}
const cursorId = parseImMessageCursorId(get().messagesCursor)
if (cursorId <= 0) {
return
}
set({ messagesLoadingMore: true })
try {
const page = await fetchImMessages({
conversationId,
cursor: cursorId,
limit: DEFAULT_PAGE_LIMIT,
})
const results = ensureMessageList(page.results)
set((state) => {
const merged = mergeImMessagesByIdAsc(
ensureMessageList(state.messages),
results
)
return {
messages: merged,
messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "",
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
messagesLoadingMore: false,
}
})
} catch (error) {
set({
messagesLoadingMore: false,
error: error instanceof Error ? error.message : t("supportChat.loadHistoryFailed"),
})
throw error
}
},
markConversationRead: async () => {
const state = get()
const conversation = state.conversation
const lastMessage = state.messages.at(-1)
if (!conversation?.id || !lastMessage) {
return
}
if (
conversation.customerUnreadCount <= 0 &&
conversation.customerLastReadMessageId >= lastMessage.id
) {
return
}
if (state.readingMessageId === lastMessage.id) {
return
}
set({ readingMessageId: lastMessage.id })
try {
await markImMessageRead(conversation.id, lastMessage.id)
set((current) => ({
readingMessageId: 0,
messages: current.messages.map((item) => {
if (item.id > lastMessage.id) {
return item
}
return item.customerRead ? item : { ...item, customerRead: true }
}),
...withActiveConversationPatch(current, {
customerUnreadCount: 0,
customerLastReadMessageId: lastMessage.id,
}),
}))
} catch (error) {
set({ readingMessageId: 0 })
throw error
}
},
handleSendMessage: async (content: string) => {
const conversationId = get().conversation?.id
if (!conversationId) {
return
}
set({ error: "", sending: true })
try {
const nextMessage = await sendImMessage({
conversationId,
messageType: "html",
content,
clientMsgId: `support_chat_html_${generateUUID()}`,
})
set((state) => ({
sending: false,
messages: state.messages.some((message) => message.id === nextMessage.id)
? state.messages.map((message) =>
message.id === nextMessage.id ? nextMessage : message
)
: [...state.messages, nextMessage],
...withActiveConversationPatch(state, {
customerLastReadMessageId: nextMessage.id,
customerUnreadCount: 0,
lastMessageAt: nextMessage.sentAt,
lastMessageSummary: summarizeIMMessage(nextMessage),
}),
}))
if (get().isOpen && (!get().socket || get().socket?.readyState !== WebSocket.OPEN)) {
connectSocket()
}
scheduleMessageReplyCheck(conversationId, nextMessage.id)
} catch (error) {
set({
sending: false,
error: error instanceof Error ? error.message : t("supportChat.sendMessageFailed"),
})
throw error
}
},
sendMessage: async (content: string) => {
return get().handleSendMessage(content)
},
uploadMessageImage: async (file: File) => {
const conversationId = get().conversation?.id
if (!conversationId) {
return null
}
set({ error: "", uploadingAsset: true })
try {
return await uploadImImage(conversationId, file)
} catch (error) {
set({
error: error instanceof Error ? error.message : t("supportChat.uploadImageFailed"),
})
return null
} finally {
set({ uploadingAsset: false })
}
},
sendAttachment: async (file: File) => {
const conversationId = get().conversation?.id
if (!conversationId) {
return
}
set({ error: "", uploadingAsset: true })
try {
const asset = await uploadImAttachment(conversationId, file)
const nextMessage = await sendImMessage({
conversationId,
messageType: "attachment",
content: asset.filename,
payload: JSON.stringify({ assetId: asset.assetId }),
clientMsgId: `support_chat_attachment_${generateUUID()}`,
})
set((state) => ({
uploadingAsset: false,
messages: state.messages.some((message) => message.id === nextMessage.id)
? state.messages.map((message) =>
message.id === nextMessage.id ? nextMessage : message
)
: [...state.messages, nextMessage],
...withActiveConversationPatch(state, {
customerLastReadMessageId: nextMessage.id,
customerUnreadCount: 0,
lastMessageAt: nextMessage.sentAt,
lastMessageSummary: summarizeIMMessage(nextMessage),
}),
}))
if (get().isOpen && (!get().socket || get().socket?.readyState !== WebSocket.OPEN)) {
connectSocket()
}
scheduleMessageReplyCheck(conversationId, nextMessage.id)
} catch (error) {
set({
uploadingAsset: false,
error: error instanceof Error ? error.message : t("supportChat.sendAttachmentFailed"),
})
throw error
}
},
closeConversation: async () => {
const conversationId = get().conversation?.id
if (!conversationId) {
return
}
set({ error: "", closingConversation: true })
try {
await closeImConversation(conversationId)
closeSocket({ reconnect: false })
set((state) => ({
closingConversation: false,
status: "disconnected",
activeAgentActivity: null,
...withActiveConversationPatch(state, {
status: CLOSED_IM_CONVERSATION_STATUS,
customerUnreadCount: 0,
}),
}))
} catch (error) {
set({
closingConversation: false,
error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"),
})
throw error
}
},
setConversationSatisfaction: (satisfaction: ConversationSatisfactionResponse) => {
set((state) => ({
...withActiveConversationPatch(state, {
satisfaction,
}),
}))
},
retry: async () => {
if (!get().conversation?.id) {
return
}
set({ error: "", status: "connecting" })
try {
await get().refreshMessages()
if (get().isOpen) {
connectSocket()
}
} catch (error) {
set({
status: "disconnected",
error: error instanceof Error ? error.message : t("supportChat.refreshFailed"),
})
}
},
}
})