// Modified in the independent hardening fork; see docs/hardening/README.md. "use client" import { create } from "zustand" import { closeImConversation, createImConversation, createOrMatchImConversation, ensureCustomerSession, getCustomerEntryScope, getCustomerIdentityScope, 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 { getMessageFileIdentity } from "@/lib/message-file-identity" 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, 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), } } /** * Route a captured request's changes through the list, deriving the active * request separately so a background acknowledgment cannot change its target. */ function withConversationPatch( state: Pick, conversationId: number, patch: Partial ) { const active = findConversationById( state.conversations, conversationId ) 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?: Partial> ) { 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 = { identityScope: string 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: () => Promise disconnectSocket: () => void refreshConversations: () => Promise selectConversation: (conversationId: number) => Promise startNewConversation: (subject?: string) => Promise refreshMessages: () => Promise syncLatestMessages: () => Promise loadOlderMessages: () => Promise markConversationRead: () => Promise handleSendMessage: (content: string) => Promise sendMessage: (content: string) => Promise uploadMessageImage: (file: File) => Promise sendAttachment: (file: File) => Promise closeConversation: () => Promise setConversationSatisfaction: (satisfaction: ConversationSatisfactionResponse) => void retry: () => Promise } 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, error: "", } } export const useSupportChatStore = create((set, get) => { let subscribedConversationId: number | null = null let lifecycle = 0 let selection = 0 let latestRequest = 0 let historyRequest = 0 let listRequest = 0 let loadedHistorySelection = -1 let bootstrapPromise: Promise | null = null const replyTimers = new Set>() const pendingReads = new Map() const pendingSends = new Set() const pendingUploads = new Set() const pendingCloses = new Set() const textRetries = new Map() const attachmentRetries = new Map>() const capture = () => ({ lifecycle, selection, identityScope: getCustomerIdentityScope(), conversationId: get().activeConversationId, }) type RequestScope = ReturnType const isCurrentSession = (scope: RequestScope) => scope.lifecycle === lifecycle && scope.identityScope === get().identityScope && scope.identityScope === getCustomerIdentityScope() const isCurrentView = (scope: RequestScope) => isCurrentSession(scope) && scope.selection === selection && scope.conversationId === get().activeConversationId const invalidate = () => { lifecycle += 1 selection += 1 latestRequest += 1 historyRequest += 1 listRequest += 1 bootstrapPromise = null for (const timer of replyTimers) clearTimeout(timer) replyTimers.clear() pendingReads.clear() pendingSends.clear() pendingUploads.clear() pendingCloses.clear() } const activeMutationState = (conversationId: number) => ({ sending: pendingSends.has(conversationId), uploadingAsset: pendingUploads.has(conversationId), closingConversation: pendingCloses.has(conversationId), }) const resetChangedIdentity = () => { const identityScope = getCustomerIdentityScope() if (get().identityScope === identityScope) return invalidate() closeSocket({ reconnect: false }) textRetries.clear() attachmentRetries.clear() set({ identityScope, initialized: false, conversations: [], conversation: null, activeConversationId: 0, messages: [], messagesCursor: "", messagesHasMore: false, messagesLoadingMore: false, readingMessageId: 0, activeAgentActivity: null, sending: false, uploadingAsset: false, closingConversation: false, creatingConversation: false, conversationsLoading: false, error: "" }) } const requireConversation = () => { const scope = capture() if (!isCurrentSession(scope) || !get().initialized || !scope.conversationId || get().conversation?.status === CLOSED_IM_CONVERSATION_STATUS) { throw new Error(t("supportChat.sendUnavailable")) } return scope } const applyDeliveredMessage = (scope: RequestScope, message: ImMessage) => { if (!isCurrentSession(scope) || message.conversationId !== scope.conversationId) return set((state) => { const current = findConversationById(state.conversations, scope.conversationId) if (!current) return {} // A reply/newer realtime message may have arrived before this HTTP ack. const newest = message.id >= current.lastMessageId return { ...withConversationPatch(state, scope.conversationId, { customerLastReadMessageId: Math.max(current.customerLastReadMessageId, message.id), customerUnreadCount: current.lastMessageId <= message.id ? 0 : current.customerUnreadCount, ...(newest ? { lastMessageId: message.id, lastMessageAt: message.sentAt, lastActiveAt: message.sentAt, lastMessageSummary: summarizeIMMessage(message), } : {}), }), ...(isCurrentView(scope) ? { messages: markMessagesReadToMessageId(mergeImMessagesByIdAsc(state.messages, [message]), message.id, "customer"), } : {}), } }) if (!isCurrentView(scope) && get().activeConversationId === scope.conversationId) { // The visitor left and returned while sending. Fetch under the new view // generation instead of applying the old generation's message snapshot. void get().syncLatestMessages() } } 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 = (scope: RequestScope, sentMessageId: number) => { for (const delay of [1500, 3500, 7000]) { const timer = setTimeout(() => { replyTimers.delete(timer) if (!isCurrentView(scope)) return const current = get() if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) { connectSocket() } if (!current.messages.some((message) => message.id > sentMessageId)) { void current.syncLatestMessages() } }, delay) replyTimers.add(timer) } } const realtime = createRealtimeConnectionManager({ beforeConnect: async () => { const scope = capture() await ensureCustomerSession() if (!isCurrentSession(scope)) { if (scope.lifecycle === lifecycle && get().isOpen) void get().bootstrap() throw new Error(t("supportChat.sendUnavailable")) } }, createSocket: createImRealtimeConnection, canReconnect: () => Boolean(get().isOpen && get().initialized && get().conversation?.id && get().identityScope === getCustomerIdentityScope()), onStatusChange: (status) => { if (get().isOpen || status === "disconnected") { set({ status }) } }, onSocketChange: (socket) => { set({ socket }) }, onOpen: (socket) => { if (!get().isOpen || !get().initialized || get().identityScope !== getCustomerIdentityScope()) { closeSocket({ reconnect: false }) return } 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) => { if (get().identityScope !== getCustomerIdentityScope()) return 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(payload) if (!message) { if (isActive) { set({ activeAgentActivity: null }) void get().syncLatestMessages() } return } set((state) => { const conversations = patchConversationListWithMessage( state.conversations.filter((item) => item.id !== message.conversationId || message.id >= item.lastMessageId), message ) const updatedConversations = [...conversations, ...state.conversations.filter((item) => item.id === message.conversationId && message.id < item.lastMessageId)] 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, updatedConversations) } return { ...withConversations(state, updatedConversations), 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 { identityScope: "", 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) => { resetChangedIdentity() set({ isOpen }) if (!isOpen) get().disconnectSocket() }, setIsVisible: (isVisible: boolean) => { set({ isVisible }) }, bootstrap: () => { resetChangedIdentity() const identityScope = getCustomerIdentityScope() if (!get().isOpen) { get().disconnectSocket() return Promise.resolve() } if (bootstrapPromise && get().identityScope === identityScope) return bootstrapPromise if (get().initialized && get().identityScope === identityScope && get().conversation) { connectSocket() return get().syncLatestMessages() } invalidate() closeSocket({ reconnect: false }) if (get().identityScope !== identityScope) { textRetries.clear() attachmentRetries.clear() } set({ identityScope, initialized: false, conversations: [], conversation: null, activeConversationId: 0, activeAgentActivity: null, messages: [], messagesCursor: "", messagesHasMore: false, messagesLoadingMore: false, readingMessageId: 0, sending: false, uploadingAsset: false, closingConversation: false, creatingConversation: false, conversationsLoading: false, error: "", status: "connecting", }) let scope = capture() const activateChat = async () => { try { const widgetConfig: ImWidgetConfig = await fetchImWidgetConfig().catch(() => ({})) if (!isCurrentSession(scope) || !get().isOpen) return if (widgetConfig.channelId) { setSupportChatRuntimeConfig({ ...readSupportChatRuntimeConfig(), channelId: widgetConfig.channelId }) set({ identityScope: getCustomerIdentityScope() }) scope = capture() } set({ title: widgetConfig.title || t("supportChat.title"), subtitle: widgetConfig.subtitle || "", themeColor: widgetConfig.themeColor || "#2563eb", promptStarters: widgetConfig.promptStarters || [], }) const entryScope = getCustomerEntryScope() await ensureCustomerSession() if (scope.lifecycle !== lifecycle || entryScope !== getCustomerEntryScope() || !get().isOpen) return const establishedIdentityScope = getCustomerIdentityScope() if (establishedIdentityScope !== get().identityScope) { textRetries.clear() attachmentRetries.clear() } set({ identityScope: establishedIdentityScope }) scope = capture() // A failed list read is not evidence that the visitor has no requests. // Surface the failure for retry rather than creating an accidental one. const page = await fetchImConversationList({ limit: CONVERSATION_LIST_PAGE_LIMIT }) if (!isCurrentSession(scope) || !get().isOpen) return const conversations = Array.isArray(page?.results) ? page.results : [] let resumedConversation = pickConversationToResume(conversations) if (!resumedConversation) { resumedConversation = await createOrMatchImConversation() if (!isCurrentSession(scope) || !get().isOpen) return } const resumed = resumedConversation selection += 1 set((state) => ({ initialized: true, ...withConversations(state, [resumed, ...conversations.filter((item) => item.id !== resumed.id)], resumed.id), })) await get().refreshMessages() if (!isCurrentSession(scope) || !get().isOpen) return connectSocket() } catch (error) { if (!isCurrentSession(scope) || !get().isOpen) return set({ status: "disconnected", error: error instanceof Error ? error.message : t("supportChat.initFailed") }) } } const promise = activateChat() bootstrapPromise = promise void promise.finally(() => { if (bootstrapPromise === promise) bootstrapPromise = null }) return promise }, disconnectSocket: () => { invalidate() closeSocket({ reconnect: false }) set({ initialized: false, sending: false, uploadingAsset: false, closingConversation: false, creatingConversation: false, conversationsLoading: false, messagesLoadingMore: false, readingMessageId: 0 }) }, refreshConversations: async () => { const scope = capture() if (!isCurrentSession(scope)) return const requestId = ++listRequest const before = get().conversations set({ conversationsLoading: true }) try { const page = await fetchImConversationList({ limit: CONVERSATION_LIST_PAGE_LIMIT }) if (!isCurrentSession(scope) || requestId !== listRequest) return const results = Array.isArray(page?.results) ? page.results : [] set((state) => { const merged = results.map((item) => { const previous = findConversationById(before, item.id) const current = findConversationById(state.conversations, item.id) if (!previous || !current) return current ?? item // Preserve locally/realtime changed fields since this list request. const changes = Object.fromEntries(Object.entries(current).filter(([key, value]) => value !== previous[key as keyof ImConversation])) return { ...item, ...changes } }) return { conversationsLoading: false, ...withConversations(state, [...merged, ...state.conversations.filter((item) => !results.some((result) => result.id === item.id))]) } }) } catch (error) { if (!isCurrentSession(scope) || requestId !== listRequest) return set({ conversationsLoading: false, error: error instanceof Error ? error.message : t("supportChat.loadConversationsFailed") }) } }, selectConversation: async (conversationId: number) => { if (get().activeConversationId === conversationId) return const patch = buildSelectConversationPatch(get(), conversationId) if (!patch || get().identityScope !== getCustomerIdentityScope()) return selection += 1 set({ ...patch, ...activeMutationState(conversationId), activeAgentActivity: null }) if (get().isOpen) connectSocket() try { await get().refreshMessages() } catch { // The scoped loader already exposes the error beside the active chat. } }, startNewConversation: async (subject?: string) => { if (get().creatingConversation) return const scope = capture() if (!isCurrentSession(scope) || !get().initialized) throw new Error(t("supportChat.sendUnavailable")) set({ creatingConversation: true, error: "" }) try { const created = await createImConversation(subject) if (!isCurrentSession(scope)) return const activate = isCurrentView(scope) if (activate) selection += 1 set((state) => ({ creatingConversation: false, ...withConversations(state, [created, ...state.conversations.filter((item) => item.id !== created.id)], activate ? created.id : undefined), ...(activate ? { messages: [], messagesCursor: "", messagesHasMore: false, messagesLoadingMore: false, readingMessageId: 0, activeAgentActivity: null, ...activeMutationState(created.id) } : {}), })) if (!activate) return const createdScope = capture() if (get().isOpen) connectSocket() await get().refreshMessages() if (isCurrentView(createdScope)) scheduleMessageReplyCheck(createdScope, get().messages.at(-1)?.id ?? 0) } catch (error) { if (isCurrentSession(scope)) set({ creatingConversation: false, ...(isCurrentView(scope) ? { error: error instanceof Error ? error.message : t("supportChat.createConversationFailed") } : {}) }) throw error } }, refreshMessages: async () => { const scope = capture() if (!scope.conversationId || !isCurrentSession(scope)) return const requestId = ++latestRequest try { const page = await fetchImMessages({ conversationId: scope.conversationId, limit: DEFAULT_PAGE_LIMIT }) if (!isCurrentView(scope) || requestId !== latestRequest) return const batch = ensureMessageList(page.results).filter((item) => item.conversationId === scope.conversationId) const historyWasLoaded = loadedHistorySelection === selection loadedHistorySelection = selection set((state) => { const merged = markConversationReadMessages(mergeImMessagesByIdAsc(state.messages, batch), state.conversation ?? undefined) return { messages: merged, messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", messagesHasMore: hasMoreAfterLatestImMessageMerge({ previousMessages: historyWasLoaded ? state.messages : [], previousHasMore: state.messagesHasMore, merged, apiHasMore: Boolean(page.hasMore) || batch.length >= DEFAULT_PAGE_LIMIT }), } }) } catch (error) { if (!isCurrentView(scope) || requestId !== latestRequest) return set({ error: error instanceof Error ? error.message : t("supportChat.loadMessagesFailed") }) throw error } }, syncLatestMessages: async () => { try { await get().refreshMessages() } catch { // The loader reports only errors belonging to this request generation. } }, loadOlderMessages: async () => { const scope = capture() if (!scope.conversationId || !isCurrentSession(scope) || get().messagesLoadingMore || !get().messagesHasMore) return const cursorId = parseImMessageCursorId(get().messagesCursor) if (cursorId <= 0) return const requestId = ++historyRequest set({ messagesLoadingMore: true }) try { const page = await fetchImMessages({ conversationId: scope.conversationId, cursor: cursorId, limit: DEFAULT_PAGE_LIMIT }) if (!isCurrentView(scope) || requestId !== historyRequest) return const results = ensureMessageList(page.results).filter((item) => item.conversationId === scope.conversationId) set((state) => { const merged = mergeImMessagesByIdAsc(state.messages, results) return { messages: merged, messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, messagesLoadingMore: false } }) } catch (error) { if (!isCurrentView(scope) || requestId !== historyRequest) return set({ messagesLoadingMore: false, error: error instanceof Error ? error.message : t("supportChat.loadHistoryFailed") }) throw error } }, markConversationRead: async () => { const scope = capture() const state = get() const conversation = state.conversation const lastMessage = state.messages.at(-1) if (!isCurrentSession(scope) || !conversation || !lastMessage) return if (conversation.customerUnreadCount <= 0 && conversation.customerLastReadMessageId >= lastMessage.id) return if ((pendingReads.get(conversation.id) ?? 0) >= lastMessage.id) return pendingReads.set(conversation.id, lastMessage.id) set({ readingMessageId: lastMessage.id }) try { await markImMessageRead(conversation.id, lastMessage.id) if (!isCurrentSession(scope)) return set((current) => { const target = findConversationById(current.conversations, conversation.id) if (!target) return {} return { ...withConversationPatch(current, conversation.id, { customerLastReadMessageId: Math.max(target.customerLastReadMessageId, lastMessage.id), // Never erase unread replies received after this read was sent. customerUnreadCount: target.lastMessageId <= lastMessage.id ? 0 : target.customerUnreadCount, }), ...(isCurrentView(scope) ? { messages: markMessagesReadToMessageId(current.messages, lastMessage.id, "customer"), } : {}), } }) } finally { if (isCurrentSession(scope) && pendingReads.get(conversation.id) === lastMessage.id) { pendingReads.delete(conversation.id) if (isCurrentView(scope)) set({ readingMessageId: 0 }) } } }, handleSendMessage: async (content: string) => { const scope = requireConversation() if (pendingSends.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) const retryKey = JSON.stringify([scope.identityScope, scope.conversationId, content]) const clientMsgId = textRetries.get(retryKey) ?? `support_chat_html_${generateUUID()}` textRetries.set(retryKey, clientMsgId) pendingSends.add(scope.conversationId) set({ error: "", sending: true }) try { const nextMessage = await sendImMessage({ conversationId: scope.conversationId, messageType: "html", content, clientMsgId }) // A successful delivery still resolves after switching requests. The // editor clears only that submitted draft revision, not the new draft. if (!isCurrentSession(scope)) return textRetries.delete(retryKey) applyDeliveredMessage(scope, nextMessage) if (get().isOpen) connectSocket() scheduleMessageReplyCheck(scope, nextMessage.id) } catch (error) { if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.sendMessageFailed") }) throw error } finally { if (isCurrentSession(scope)) { pendingSends.delete(scope.conversationId) if (get().activeConversationId === scope.conversationId) set({ sending: false }) } } }, sendMessage: async (content: string) => { return get().handleSendMessage(content) }, uploadMessageImage: async (file: File) => { const scope = requireConversation() if (pendingUploads.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) pendingUploads.add(scope.conversationId) set({ error: "", uploadingAsset: true }) try { const asset = await uploadImImage(scope.conversationId, file) if (!isCurrentSession(scope)) return null return asset } catch (error) { if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.uploadImageFailed") }) return null } finally { if (isCurrentSession(scope)) { pendingUploads.delete(scope.conversationId) if (get().activeConversationId === scope.conversationId) set({ uploadingAsset: false }) } } }, sendAttachment: async (file: File) => { const scope = requireConversation() if (pendingUploads.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) const retries = attachmentRetries.get(scope.conversationId) ?? new Map() attachmentRetries.set(scope.conversationId, retries) pendingUploads.add(scope.conversationId) set({ error: "", uploadingAsset: true }) try { const retryKey = await getMessageFileIdentity(file) if (!isCurrentSession(scope)) throw new Error(t("supportChat.sendUnavailable")) const attempt = retries.get(retryKey) ?? { clientMsgId: `support_chat_attachment_${generateUUID()}`, asset: undefined } retries.set(retryKey, attempt) attempt.asset ??= await uploadImAttachment(scope.conversationId, file) // Never send an uploaded asset using a different session's credentials. if (!isCurrentSession(scope)) throw new Error(t("supportChat.sendUnavailable")) const nextMessage = await sendImMessage({ conversationId: scope.conversationId, messageType: "attachment", content: attempt.asset.filename, payload: JSON.stringify({ assetId: attempt.asset.assetId }), clientMsgId: attempt.clientMsgId }) if (!isCurrentSession(scope)) return retries.delete(retryKey) applyDeliveredMessage(scope, nextMessage) if (get().isOpen) connectSocket() scheduleMessageReplyCheck(scope, nextMessage.id) } catch (error) { if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.sendAttachmentFailed") }) throw error } finally { if (isCurrentSession(scope)) { pendingUploads.delete(scope.conversationId) if (get().activeConversationId === scope.conversationId) set({ uploadingAsset: false }) } } }, closeConversation: async () => { const scope = requireConversation() if (pendingCloses.has(scope.conversationId)) return pendingCloses.add(scope.conversationId) set({ error: "", closingConversation: true }) try { await closeImConversation(scope.conversationId) if (!isCurrentSession(scope)) return set((state) => ({ ...withConversationPatch(state, scope.conversationId, { status: CLOSED_IM_CONVERSATION_STATUS }), ...(isCurrentView(scope) ? { activeAgentActivity: null } : {}), })) // Keep the transport alive for the request list; closing a background // request must not disconnect the newly selected request. } catch (error) { if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed") }) throw error } finally { if (isCurrentSession(scope)) { pendingCloses.delete(scope.conversationId) if (get().activeConversationId === scope.conversationId) set({ closingConversation: false }) } } }, setConversationSatisfaction: (satisfaction: ConversationSatisfactionResponse) => { if (!get().initialized || get().identityScope !== getCustomerIdentityScope()) return set((state) => withConversationPatch(state, satisfaction.conversationId, { satisfaction })) }, retry: async () => { if (!get().initialized || !get().conversation?.id || get().identityScope !== getCustomerIdentityScope()) { await get().bootstrap() return } const scope = capture() set({ error: "", status: "connecting" }) try { await ensureCustomerSession() if (!isCurrentSession(scope)) { if (scope.lifecycle === lifecycle && get().isOpen) await get().bootstrap() return } await get().refreshMessages() if (isCurrentView(scope) && get().isOpen) connectSocket() } catch (error) { if (!isCurrentView(scope)) return set({ status: "disconnected", error: error instanceof Error ? error.message : t("supportChat.refreshFailed") }) } }, } })