Files
agent-desk-ng/web/lib/stores/support-chat.ts
mono 63e0457886 fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
2026-10-16 12:15:20 +00:00

1006 lines
37 KiB
TypeScript

// 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<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),
}
}
/**
* 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<SupportChatStore, "conversations" | "activeConversationId">,
conversationId: number,
patch: Partial<ImConversation>
) {
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<Pick<ImConversation, "agentLastReadMessageId" | "agentLastReadAt" |
"customerLastReadMessageId" | "customerLastReadAt">>
) {
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<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>
}
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<SupportChatStore>((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<void> | null = null
const replyTimers = new Set<ReturnType<typeof setTimeout>>()
const pendingReads = new Map<number, number>()
const pendingSends = new Set<number>()
const pendingUploads = new Set<number>()
const pendingCloses = new Set<number>()
const textRetries = new Map<string, string>()
const attachmentRetries = new Map<number, Map<string, { clientMsgId: string; asset?: ImAsset }>>()
const capture = () => ({
lifecycle,
selection,
identityScope: getCustomerIdentityScope(),
conversationId: get().activeConversationId,
})
type RequestScope = ReturnType<typeof capture>
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<ImMessage>(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<string, { clientMsgId: string; asset?: ImAsset }>()
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") })
}
},
}
})