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.
1006 lines
37 KiB
TypeScript
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") })
|
|
}
|
|
},
|
|
}
|
|
})
|