fix: recover chat replies and organize setup navigation [skip ci]

Targeted source-only patch from reviewed local e370485. Adds bounded realtime/catch-up recovery and moves the setup wizard under System. Static checks only; regression tests, full build and runtime verification have not been run. No deployment. Separate hardening-review branch is not merged. Expected tree: 6235478b9a.
This commit is contained in:
2026-10-16 13:43:41 +00:00
parent e57610a27c
commit 299ea50fb5
8 changed files with 843 additions and 72 deletions

View File

@@ -0,0 +1,68 @@
# Support chat realtime recovery
This targeted change is based on canonical Gitea main snapshot
`e57610a27c556e005b9b3b68115477ba1fdd3f1b` (tree
`9cf938c2498ce6b63faaad7cf8b286c1cfe7a5ef`). It does not import the separate
hardening-review branch.
## Report and source findings
The reported scenario is opening a new chat from a previous one: sending works,
but replies appear only after a page reload. Source inspection does **not** prove
why the deployed WebSocket fails. The deployed version, reverse proxy, browser
handshake and network have not been observed or tested for this report.
The source does establish these recovery gaps:
- A socket in `CONNECTING` has no deadline; subsequent create/select/send paths
see it and return without starting a replacement.
- HTTP fallback checks end after 1.5, 3.5 and 7 seconds, so a later reply is not
retrieved while realtime remains unavailable.
- Reusing an open socket on create/select bypasses the shared status repair path.
- Initial history failure prevents bootstrap/retry from starting realtime.
- A replaced socket's late close can clear the current heartbeat/subscription
bookkeeping. A late close-conversation response can affect a newer selection.
## Changes
- Bound connection/authentication attempts to 15 seconds, invalidate abandoned
attempts, and retry using the existing exponential reconnect policy.
- Ignore close events from replaced sockets before touching current state.
- Route new/selected chats through the existing subscription/connection helper.
- Fetch late replies every 5 seconds only while the chat is open, visible, has
an active nonclosed conversation, and realtime is not connected. Never label
HTTP fallback as an online WebSocket.
- Keep latest-message refresh single-flight with a 15-second abort deadline.
Cancel previous-view requests and fence their completion before starting a new
one. Ignore stale identity/view results, stop recovery on connection, hiding,
close or teardown, and recover on focus, visibility or network return.
Background recovery failures remain quiet. The optional API AbortSignal is
backward-compatible with existing history/pagination callers.
- Connect independently of initial HTTP history success and merge history so it
cannot remove a reply already delivered by realtime.
- Restrict a close response to the conversation it actually closed.
No backend, SDK bundle, credentials, database, deployment configuration or
production data is changed.
## Validation status
Completed static checks only:
- `git diff --check`.
- Isolated strict TypeScript compile of `web/lib/realtime-connection.ts` using an
already installed TypeScript 6.0.3 compiler.
- TypeScript syntax/transpile diagnostics for the four changed production files.
- JavaScript syntax checks for the two focused regression files.
Added offline mock regressions for delayed replies beyond seven seconds, moving
from a previous chat, resubscription, stuck handshakes, late authentication and
socket close events, hidden/unmounted lifecycle, single-flight fallback,
identity/view changes, abort deadlines, stalled previous-view fetches, history
failures and late close responses.
**The regression tests have not been executed.** Full project typecheck, lint,
build and browser/runtime validation have not been run; permission for tests,
installation and runtime verification remains pending. No services were started,
real support messages sent, or deployment performed. Do not describe this source
patch as a verified production fix.

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
"use client" "use client"
import { import {
@@ -139,6 +140,7 @@ function SupportChatPanelContent() {
sendAttachment, sendAttachment,
retry, retry,
disconnectSocket, disconnectSocket,
recoverConnection,
markConversationRead, markConversationRead,
closeConversation, closeConversation,
setConversationSatisfaction, setConversationSatisfaction,
@@ -164,6 +166,7 @@ function SupportChatPanelContent() {
sendAttachment: state.sendAttachment, sendAttachment: state.sendAttachment,
retry: state.retry, retry: state.retry,
disconnectSocket: state.disconnectSocket, disconnectSocket: state.disconnectSocket,
recoverConnection: state.recoverConnection,
markConversationRead: state.markConversationRead, markConversationRead: state.markConversationRead,
closeConversation: state.closeConversation, closeConversation: state.closeConversation,
setConversationSatisfaction: state.setConversationSatisfaction, setConversationSatisfaction: state.setConversationSatisfaction,
@@ -206,13 +209,9 @@ function SupportChatPanelContent() {
useEffect(() => { useEffect(() => {
bootstrap() bootstrap()
}, [isOpen, bootstrap])
return () => { useEffect(() => () => disconnectSocket(), [disconnectSocket])
if (!isOpen) {
disconnectSocket()
}
}
}, [isOpen, bootstrap, disconnectSocket])
useEffect(() => { useEffect(() => {
maybeMarkConversationRead() maybeMarkConversationRead()
@@ -220,21 +219,25 @@ function SupportChatPanelContent() {
useEffect(() => { useEffect(() => {
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
recoverConnection()
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
maybeMarkConversationRead() maybeMarkConversationRead()
} }
} }
const handleFocus = () => { const handleFocus = () => {
recoverConnection()
maybeMarkConversationRead() maybeMarkConversationRead()
} }
document.addEventListener("visibilitychange", handleVisibilityChange) document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("focus", handleFocus) window.addEventListener("focus", handleFocus)
window.addEventListener("online", recoverConnection)
return () => { return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange) document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus) window.removeEventListener("focus", handleFocus)
window.removeEventListener("online", recoverConnection)
} }
}, [maybeMarkConversationRead]) }, [maybeMarkConversationRead, recoverConnection])
async function handleSend(content: string) { async function handleSend(content: string) {
await handleSendMessage(content) await handleSendMessage(content)

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
import { request } from "@/lib/api/client" import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
@@ -472,11 +473,12 @@ export function fetchImConversationDetail(id: number) {
} }
export function fetchImMessages( export function fetchImMessages(
query?: Record<string, string | number | undefined> query?: Record<string, string | number | undefined>,
options?: { signal?: AbortSignal }
) { ) {
return request<PageResult<ImMessage>>( return request<PageResult<ImMessage>>(
`/api/message/list${toQueryString(query)}`, `/api/message/list${toQueryString(query)}`,
createRequestOptions() createRequestOptions({ signal: options?.signal })
) )
} }

View File

@@ -1,3 +1,4 @@
// Modified to move the setup wizard navigation item into the System section.
import { import {
BotMessageSquareIcon, BotMessageSquareIcon,
BookOpenIcon, BookOpenIcon,
@@ -107,11 +108,6 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
url: "/dashboard", url: "/dashboard",
icon: <LayoutDashboardIcon />, icon: <LayoutDashboardIcon />,
}, },
{
titleKey: "nav.setupWizard",
url: "/dashboard/setup",
icon: <ListChecksIcon />,
},
{ {
titleKey: "nav.conversations", titleKey: "nav.conversations",
url: "/dashboard/conversations", url: "/dashboard/conversations",
@@ -268,6 +264,11 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
titleKey: "nav.system", titleKey: "nav.system",
icon: <ShieldCheckIcon />, icon: <ShieldCheckIcon />,
items: [ items: [
{
titleKey: "nav.setupWizard",
url: "/dashboard/setup",
icon: <ListChecksIcon />,
},
{ {
titleKey: "nav.users", titleKey: "nav.users",
url: "/dashboard/users", url: "/dashboard/users",

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
@@ -97,3 +98,113 @@ test("beforeConnect failure triggers onConnectError and disconnect status", asyn
assert.ok(statusList.includes("disconnected"), "status should include disconnected") assert.ok(statusList.includes("disconnected"), "status should include disconnected")
manager.disconnect() manager.disconnect()
}) })
test("a stuck CONNECTING socket times out and retries without reloading", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] })
const sockets = []
const manager = createRealtimeConnectionManager({
connectTimeoutMs: 100,
reconnectBaseDelayMs: 10,
createSocket: () => { const socket = new MockWebSocket(); sockets.push(socket); return socket },
})
t.after(() => manager.disconnect())
await manager.connect()
t.mock.timers.tick(100)
assert.equal(sockets[0].readyState, MockWebSocket.CLOSED)
t.mock.timers.tick(10)
assert.equal(sockets.length, 2)
sockets[1].open()
t.mock.timers.tick(100)
assert.equal(manager.getSocket(), sockets[1])
})
test("timed out authentication cannot open a late socket after a newer connection", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] })
let resolveOld, attempts = 0, created = 0
const old = new Promise((resolve) => { resolveOld = resolve })
const manager = createRealtimeConnectionManager({
connectTimeoutMs: 100,
reconnectBaseDelayMs: 10,
beforeConnect: () => ++attempts === 1 ? old : Promise.resolve(),
createSocket: () => { created++; return new MockWebSocket() },
})
t.after(() => manager.disconnect())
const first = manager.connect()
t.mock.timers.tick(100)
t.mock.timers.tick(10)
await Promise.resolve()
resolveOld()
await first
assert.equal(created, 1)
})
test("late replaced socket close cannot clear the current heartbeat or subscription", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] })
let closed = 0, pings = 0
const sockets = []
const manager = createRealtimeConnectionManager({
pingIntervalMs: 10,
createSocket: () => {
const socket = new MockWebSocket()
socket.close = () => { socket.readyState = MockWebSocket.CLOSED }
socket.send = () => { pings++ }
sockets.push(socket)
return socket
},
onClose: () => { closed++ },
})
t.after(() => manager.disconnect())
await manager.connect()
sockets[0].open()
await manager.connect()
sockets[1].open()
sockets[0].emit("close", {})
t.mock.timers.tick(10)
assert.equal(manager.getSocket(), sockets[1])
assert.equal(closed, 0)
assert.equal(pings, 1)
})
test("an error without a close event detaches a connecting socket and retries", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] })
const sockets = []
const manager = createRealtimeConnectionManager({
connectTimeoutMs: 100,
reconnectBaseDelayMs: 10,
createSocket: () => {
const socket = new MockWebSocket()
socket.close = () => { socket.readyState = MockWebSocket.CLOSING }
sockets.push(socket)
return socket
},
})
t.after(() => manager.disconnect())
await manager.connect()
sockets[0].emit("error", {})
assert.equal(manager.getSocket(), null)
t.mock.timers.tick(10)
assert.equal(sockets.length, 2)
sockets[1].open()
t.mock.timers.tick(100)
assert.equal(manager.getSocket(), sockets[1])
})
test("becoming ineligible during authentication clears the opening deadline", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] })
let resolveAuth, allowed = true, errors = 0
const auth = new Promise((resolve) => { resolveAuth = resolve })
const manager = createRealtimeConnectionManager({
connectTimeoutMs: 100,
canReconnect: () => allowed,
beforeConnect: () => auth,
createSocket: () => assert.fail("ineligible connection must not create a socket"),
onConnectError: () => { errors++ },
})
t.after(() => manager.disconnect())
const connecting = manager.connect()
allowed = false
resolveAuth()
await connecting
t.mock.timers.tick(200)
assert.equal(errors, 0)
})

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
"use client" "use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected" export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
@@ -14,6 +15,7 @@ type RealtimeConnectionManagerOptions = {
onError?: (event: Event, socket: WebSocket) => void onError?: (event: Event, socket: WebSocket) => void
onConnectError?: (error: unknown) => void onConnectError?: (error: unknown) => void
buildPingMessage?: () => string buildPingMessage?: () => string
connectTimeoutMs?: number
pingIntervalMs?: number pingIntervalMs?: number
reconnectBaseDelayMs?: number reconnectBaseDelayMs?: number
reconnectMaxDelayMs?: number reconnectMaxDelayMs?: number
@@ -24,6 +26,7 @@ type DisconnectOptions = {
updateStatus?: boolean updateStatus?: boolean
} }
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
const DEFAULT_PING_INTERVAL_MS = 20000 const DEFAULT_PING_INTERVAL_MS = 20000
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000 const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000 const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
@@ -32,12 +35,20 @@ export function createRealtimeConnectionManager(
options: RealtimeConnectionManagerOptions options: RealtimeConnectionManagerOptions
) { ) {
let socket: WebSocket | null = null let socket: WebSocket | null = null
let connectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null let pingTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempt = 0 let reconnectAttempt = 0
let reconnectEnabled = false let reconnectEnabled = false
let connectEpoch = 0 let connectEpoch = 0
const clearConnectTimer = () => {
if (connectTimer !== null) {
globalThis.clearTimeout(connectTimer)
connectTimer = null
}
}
const clearReconnectTimer = () => { const clearReconnectTimer = () => {
if (reconnectTimer !== null) { if (reconnectTimer !== null) {
globalThis.clearTimeout(reconnectTimer) globalThis.clearTimeout(reconnectTimer)
@@ -53,6 +64,7 @@ export function createRealtimeConnectionManager(
} }
const clearTimers = () => { const clearTimers = () => {
clearConnectTimer()
clearReconnectTimer() clearReconnectTimer()
clearPingTimer() clearPingTimer()
} }
@@ -97,6 +109,16 @@ export function createRealtimeConnectionManager(
options.onStatusChange?.("connecting") options.onStatusChange?.("connecting")
const currentEpoch = ++connectEpoch const currentEpoch = ++connectEpoch
connectTimer = globalThis.setTimeout(() => {
if (currentEpoch !== connectEpoch) {
return
}
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected")
// Callers already localize their fallback for non-Error failures.
options.onConnectError?.({ code: "realtime_connect_timeout" })
scheduleReconnect()
}, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
if (options.beforeConnect) { if (options.beforeConnect) {
try { try {
@@ -105,6 +127,7 @@ export function createRealtimeConnectionManager(
if (currentEpoch !== connectEpoch || !reconnectEnabled) { if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return return
} }
clearConnectTimer()
options.onStatusChange?.("disconnected") options.onStatusChange?.("disconnected")
options.onConnectError?.(error) options.onConnectError?.(error)
scheduleReconnect() scheduleReconnect()
@@ -112,11 +135,12 @@ export function createRealtimeConnectionManager(
} }
} }
if ( if (currentEpoch !== connectEpoch || !reconnectEnabled) {
currentEpoch !== connectEpoch || return
!reconnectEnabled || }
!(options.canReconnect?.() ?? true) if (!(options.canReconnect?.() ?? true)) {
) { clearConnectTimer()
options.onStatusChange?.("disconnected")
return return
} }
@@ -124,6 +148,7 @@ export function createRealtimeConnectionManager(
try { try {
nextSocket = options.createSocket() nextSocket = options.createSocket()
} catch (error) { } catch (error) {
clearConnectTimer()
options.onStatusChange?.("disconnected") options.onStatusChange?.("disconnected")
options.onConnectError?.(error) options.onConnectError?.(error)
scheduleReconnect() scheduleReconnect()
@@ -136,6 +161,7 @@ export function createRealtimeConnectionManager(
if (socket !== nextSocket) { if (socket !== nextSocket) {
return return
} }
clearConnectTimer()
clearReconnectTimer() clearReconnectTimer()
clearPingTimer() clearPingTimer()
reconnectAttempt = 0 reconnectAttempt = 0
@@ -157,15 +183,15 @@ export function createRealtimeConnectionManager(
}) })
nextSocket.addEventListener("close", (event) => { nextSocket.addEventListener("close", (event) => {
clearPingTimer() // A replaced socket may finish closing after the next one has opened.
const isCurrentSocket = socket === nextSocket // It must not clear that connection's heartbeat or subscription state.
if (isCurrentSocket) { if (socket !== nextSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) {
return return
} }
clearConnectTimer()
clearPingTimer()
setSocket(null)
options.onClose?.(event, nextSocket)
if (canReconnect()) { if (canReconnect()) {
scheduleReconnect() scheduleReconnect()
} else { } else {
@@ -178,12 +204,17 @@ export function createRealtimeConnectionManager(
return return
} }
options.onError?.(event, nextSocket) options.onError?.(event, nextSocket)
// An error need not be followed promptly by close. Detach it now so
// callers cannot keep reusing a failed OPEN/CONNECTING socket.
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected") options.onStatusChange?.("disconnected")
scheduleReconnect() scheduleReconnect()
}) })
} }
const disconnect = (disconnectOptions?: DisconnectOptions) => { const disconnect = (disconnectOptions?: DisconnectOptions) => {
// A timed-out or abandoned authentication wait must not open a late socket.
connectEpoch += 1
reconnectEnabled = disconnectOptions?.reconnect ?? false reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers() clearTimers()
if (!reconnectEnabled) { if (!reconnectEnabled) {

View File

@@ -0,0 +1,321 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const deferred = () => {
let resolve
const promise = new Promise((done) => { resolve = done })
return { promise, resolve }
}
const conversation = (id) => ({ id, status: 3, customerUnreadCount: 0 })
const message = (id, conversationId) => ({ id, conversationId, senderType: "agent",
senderId: 1, messageType: "html", content: "Reply", sendStatus: 1,
customerRead: false, agentRead: false })
const page = (results = []) => ({ results, hasMore: false })
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
// Run actual store and merge helpers with fake transport, APIs, visibility and
// clock. These regressions never open a network socket or touch browser data.
function harness(overrides = {}) {
let state, realtime, now = 0, nextTimer = 0, connects = 0, disconnects = 0
const timers = new Map(), modules = new Map(), sent = []
const config = { channelId: "fixture", externalId: "visitor-1" }
const document = { visibilityState: "visible" }
const api = {
ensureCustomerSession: async () => ({}),
fetchImWidgetConfig: async () => ({}),
fetchImConversationList: async () => page([conversation(1)]),
createOrMatchImConversation: async () => conversation(1),
createImConversation: async () => conversation(2),
fetchImMessages: async () => page(),
closeImConversation: async () => {},
applyCustomerSessionRefresh: () => {},
...overrides,
}
const mocks = {
zustand: { create: (init) => {
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
state = init(set, () => state)
return { getState: () => state, setState: set }
} },
"@/lib/api/im": api,
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
"@/lib/utils": { generateUUID: () => "fixture" },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": {
readSupportChatRuntimeConfig: () => config,
setSupportChatRuntimeConfig: (next) => Object.assign(config, next),
},
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
realtime = options
return {
connect: async () => { connects++; options.onStatusChange("connecting") },
disconnect: () => { disconnects++; options.onSocketChange(null); options.onStatusChange("disconnected") },
}
} },
}
function load(name) {
if (mocks[name]) return mocks[name]
if (modules.has(name)) return modules.get(name)
const url = name.startsWith("@/")
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
: new URL(name, import.meta.url)
const source = readFileSync(url, "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} })
const module = { exports: {} }
modules.set(name, module.exports)
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require: load, console, document, AbortController, WebSocket: { OPEN: 1, CONNECTING: 0 },
setTimeout: (callback, delay) => {
const id = ++nextTimer
timers.set(id, { callback, at: now + delay })
return id
},
clearTimeout: (id) => timers.delete(id),
})
return module.exports
}
const store = load("./support-chat.ts").useSupportChatStore
store.setState({ initialized: true, isOpen: true, isVisible: true,
conversations: [conversation(1)], conversation: conversation(1), activeConversationId: 1 })
return { store, api, config, document, timers, sent,
get connects() { return connects }, get disconnects() { return disconnects },
status: (status) => realtime.onStatusChange(status),
emit: (type, data) => realtime.onMessage({ data: JSON.stringify({ type, data }) }),
open: () => {
const socket = { readyState: 1, send: (value) => sent.push(JSON.parse(value)) }
realtime.onSocketChange(socket)
realtime.onStatusChange("connected")
realtime.onOpen(socket)
return socket
},
advance: async (ms) => {
const until = now + ms
for (;;) {
const next = [...timers].filter(([, timer]) => timer.at <= until)
.sort((a, b) => a[1].at - b[1].at)[0]
if (!next) break
now = next[1].at
timers.delete(next[0])
next[1].callback()
await flush()
}
now = until
await flush()
},
}
}
test("new chat after previous one receives a reply later than seven seconds while WS is unavailable", async () => {
let reply = false
const h = harness({ fetchImMessages: async ({ conversationId }) => page(reply ? [message(20, conversationId)] : []) })
await h.store.getState().startNewConversation()
await h.advance(8000)
reply = true
await h.advance(2000)
assert.equal(h.store.getState().messages[0].id, 20)
assert.notEqual(h.store.getState().status, "connected", "HTTP fallback must not claim WS is online")
})
test("new chat reuses an open socket, resubscribes and repairs a stale status", async () => {
const h = harness()
await h.store.getState().retry()
const socket = h.open()
await flush()
h.store.setState({ status: "disconnected" })
await h.store.getState().startNewConversation()
assert.equal(h.store.getState().socket, socket)
assert.equal(h.store.getState().status, "connected")
assert.equal(h.sent.at(-2).type, "unsubscribe")
assert.equal(h.sent.at(-1).topics[0], "conversation:2")
})
test("fallback stops on connection, hiding, closing and unmount, and resumes on visibility", async () => {
let reads = 0
const h = harness({ fetchImMessages: async () => { reads++; return page() } })
await h.store.getState().retry()
h.document.visibilityState = "hidden"
h.store.getState().recoverConnection()
const hiddenReads = reads
await h.advance(15000)
assert.equal(reads, hiddenReads)
h.document.visibilityState = "visible"
h.store.getState().recoverConnection()
await h.advance(5000)
assert.ok(reads > hiddenReads)
h.store.getState().setIsVisible(false)
const minimizedReads = reads
await h.advance(15000)
assert.equal(reads, minimizedReads)
h.store.getState().setIsVisible(true)
h.open()
await flush()
const connectedReads = reads
await h.advance(15000)
assert.equal(reads, connectedReads)
h.status("connecting")
h.store.getState().disconnectSocket()
const unmountedReads = reads
await h.advance(15000)
assert.equal(reads, unmountedReads)
assert.equal(h.timers.size, 0)
})
test("fallback has one in-flight latest refresh and cannot restart after unmount", async () => {
const pending = deferred()
const h = harness()
await h.store.getState().retry()
let reads = 0
h.api.fetchImMessages = () => { reads++; return pending.promise }
await h.advance(5000)
void h.store.getState().syncLatestMessages()
await h.advance(15000)
assert.equal(reads, 1)
h.store.getState().disconnectSocket()
pending.resolve(page([message(99, 1)]))
await flush()
await h.advance(15000)
assert.equal(reads, 1)
assert.equal(h.store.getState().messages.length, 0)
assert.equal(h.timers.size, 0)
})
for (const change of ["conversation", "identity", "round-trip selection"]) {
test(`late fallback cannot paint into a changed ${change}`, async () => {
const pending = deferred()
const h = harness()
await h.store.getState().retry()
h.api.fetchImMessages = () => pending.promise
await h.advance(5000)
h.api.fetchImMessages = async () => page()
if (change === "identity") h.config.externalId = "visitor-2"
else {
await h.store.getState().startNewConversation()
if (change === "round-trip selection") await h.store.getState().selectConversation(1)
}
pending.resolve(page([message(99, 1)]))
await flush()
assert.equal(h.store.getState().messages.length, 0)
h.store.getState().disconnectSocket()
})
}
test("bootstrap and retry attempt realtime even when loading history fails", async () => {
const h = harness({ fetchImMessages: async () => { throw new Error("history unavailable") } })
h.store.setState({ initialized: false, conversation: null, activeConversationId: 0 })
h.store.getState().bootstrap()
await flush()
assert.ok(h.connects > 0)
const before = h.connects
await h.store.getState().retry()
assert.ok(h.connects > before)
assert.equal(h.store.getState().status, "connecting")
h.store.getState().disconnectSocket()
})
test("loading initial history does not erase a newer realtime reply", async () => {
const pending = deferred()
const h = harness()
await h.store.getState().retry()
h.api.fetchImMessages = () => pending.promise
const loading = h.store.getState().refreshMessages()
h.emit("message.created", { conversationId: 1, message: message(20, 1) })
pending.resolve(page([message(10, 1)]))
await loading
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [10, 20])
})
test("late close for previous request does not close or disconnect the new chat", async () => {
const pending = deferred()
const h = harness({ closeImConversation: () => pending.promise })
const closing = h.store.getState().closeConversation()
await h.store.getState().startNewConversation()
const disconnects = h.disconnects
pending.resolve()
await closing
assert.equal(h.store.getState().conversation.id, 2)
assert.equal(h.store.getState().conversation.status, 3)
assert.equal(h.disconnects, disconnects)
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
})
test("a successful pending send cannot reconnect or start polling after unmount", async () => {
const pending = deferred()
const h = harness({ sendImMessage: () => pending.promise })
const sending = h.store.getState().handleSendMessage("hello")
h.store.getState().disconnectSocket()
pending.resolve({ ...message(10, 1), senderType: "customer" })
await sending
await h.advance(15000)
assert.equal(h.connects, 0)
assert.equal(h.timers.size, 0)
})
test("closing the active conversation stops degraded polling", async () => {
let reads = 0
const h = harness({ fetchImMessages: async () => { reads++; return page() } })
await h.store.getState().retry()
await h.store.getState().closeConversation()
const before = reads
await h.advance(15000)
assert.equal(reads, before)
assert.equal(h.timers.size, 0)
})
test("connection opening during fallback queues one fresh catch-up after its older snapshot", async () => {
const pending = deferred()
const h = harness()
await h.store.getState().retry()
h.api.fetchImMessages = () => pending.promise
await h.advance(5000)
h.open()
h.api.fetchImMessages = async () => page([message(50, 1)])
pending.resolve(page())
await flush()
assert.equal(h.store.getState().messages[0].id, 50)
assert.equal(h.store.getState().status, "connected")
assert.equal(h.timers.size, 0)
})
test("switching aborts a stalled previous-view fallback without blocking new replies", async () => {
const pending = deferred()
let previousSignal
const h = harness()
await h.store.getState().retry()
h.api.fetchImMessages = (_query, options) => { previousSignal = options?.signal; return pending.promise }
await h.advance(5000)
let replied = false
h.api.fetchImMessages = async ({ conversationId }) => page(replied ? [message(70, conversationId)] : [])
await h.store.getState().startNewConversation()
assert.equal(previousSignal.aborted, true)
replied = true
await h.advance(5000)
assert.equal(h.store.getState().messages[0].conversationId, 2)
pending.resolve(page([message(99, 1)]))
await flush()
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [70])
})
test("a stalled fallback is aborted at its deadline and can retry", async () => {
const h = harness()
await h.store.getState().retry()
let signal, reads = 0
h.api.fetchImMessages = (_query, options) => {
reads++
signal = options.signal
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }))
}
await h.advance(5000)
await h.advance(15000)
assert.equal(signal.aborted, true)
h.api.fetchImMessages = async () => { reads++; return page([message(80, 1)]) }
await h.advance(5000)
assert.equal(reads, 2)
assert.equal(h.store.getState().messages[0].id, 80)
assert.equal(h.store.getState().error, "")
})

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
"use client" "use client"
import { create } from "zustand" import { create } from "zustand"
@@ -60,6 +61,8 @@ type ChatStatus = "connecting" | "connected" | "disconnected"
const DEFAULT_PAGE_LIMIT = 50 const DEFAULT_PAGE_LIMIT = 50
const CONVERSATION_LIST_PAGE_LIMIT = 50 const CONVERSATION_LIST_PAGE_LIMIT = 50
const DISCONNECTED_MESSAGE_SYNC_INTERVAL_MS = 5000
const LATEST_MESSAGE_SYNC_TIMEOUT_MS = 15000
/** /**
* The single writer for the request list. `conversation` is never assigned * The single writer for the request list. `conversation` is never assigned
@@ -196,12 +199,13 @@ export type SupportChatStore = {
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
setIsVisible: (isVisible: boolean) => void setIsVisible: (isVisible: boolean) => void
bootstrap: () => void bootstrap: () => void
recoverConnection: () => void
disconnectSocket: () => void disconnectSocket: () => void
refreshConversations: () => Promise<void> refreshConversations: () => Promise<void>
selectConversation: (conversationId: number) => Promise<void> selectConversation: (conversationId: number) => Promise<void>
startNewConversation: (subject?: string) => Promise<void> startNewConversation: (subject?: string) => Promise<void>
refreshMessages: () => Promise<void> refreshMessages: () => Promise<void>
syncLatestMessages: () => Promise<void> syncLatestMessages: (quiet?: boolean) => Promise<void>
loadOlderMessages: () => Promise<void> loadOlderMessages: () => Promise<void>
markConversationRead: () => Promise<void> markConversationRead: () => Promise<void>
handleSendMessage: (content: string) => Promise<void> handleSendMessage: (content: string) => Promise<void>
@@ -219,6 +223,21 @@ function t(key: string) {
return translateCurrentMessage(key) return translateCurrentMessage(key)
} }
// An in-flight fallback must never carry a previous visitor's history into a
// replacement host session. Keep this comparison local; never log its contents.
function messageIdentityScope() {
const config = readSupportChatRuntimeConfig()
return JSON.stringify([
config.apiBaseUrl || config.baseUrl,
config.channelId,
config.externalId,
config.userToken,
config.externalIdSignature,
config.externalIdSignedAt,
config.requireUserToken,
])
}
function buildSelectConversationPatch( function buildSelectConversationPatch(
state: { state: {
conversations: ImConversation[] conversations: ImConversation[]
@@ -244,6 +263,74 @@ function buildSelectConversationPatch(
export const useSupportChatStore = create<SupportChatStore>((set, get) => { export const useSupportChatStore = create<SupportChatStore>((set, get) => {
let subscribedConversationId: number | null = null let subscribedConversationId: number | null = null
let recoveryTimer: ReturnType<typeof setTimeout> | null = null
let recoveryInFlight: object | null = null
let latestSync: {
controller: AbortController
timer: ReturnType<typeof setTimeout>
quiet: boolean
} | null = null
let latestSyncPending = false
let recoveryEnabled = false
let disposed = false
let viewEpoch = 0
let socketIdentity = ""
const cancelLatestSync = () => {
const pending = latestSync
latestSync = null
latestSyncPending = false
if (pending) {
clearTimeout(pending.timer)
pending.controller.abort()
}
}
const needsMessageRecovery = () => {
const state = get()
return Boolean(
!disposed &&
recoveryEnabled &&
state.isOpen &&
state.isVisible &&
(typeof document === "undefined" || document.visibilityState === "visible") &&
state.conversation?.id &&
state.conversation.status !== CLOSED_IM_CONVERSATION_STATUS &&
state.status !== "connected"
)
}
const clearMessageRecovery = () => {
recoveryInFlight = null
if (recoveryTimer !== null) {
clearTimeout(recoveryTimer)
recoveryTimer = null
}
}
const scheduleMessageRecovery = () => {
if (!needsMessageRecovery() || recoveryTimer !== null || recoveryInFlight) {
return
}
recoveryTimer = setTimeout(async () => {
recoveryTimer = null
if (!needsMessageRecovery()) {
return
}
const run = {}
recoveryInFlight = run
try {
// HTTP delivery still works when a proxy/network cannot upgrade WS.
// Keep fetching late replies, not just the first seven seconds.
await get().syncLatestMessages(true)
} finally {
if (recoveryInFlight === run) {
recoveryInFlight = null
scheduleMessageRecovery()
}
}
}, DISCONNECTED_MESSAGE_SYNC_INTERVAL_MS)
}
const sendSubscription = ( const sendSubscription = (
socket: WebSocket | null, socket: WebSocket | null,
@@ -275,10 +362,19 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
conversationId: number, conversationId: number,
sentMessageId: number sentMessageId: number
) => { ) => {
if (disposed) {
return
}
const epoch = viewEpoch
const identity = messageIdentityScope()
for (const delay of [1500, 3500, 7000]) { for (const delay of [1500, 3500, 7000]) {
setTimeout(() => { setTimeout(() => {
const current = get() const current = get()
if (current.conversation?.id === conversationId) { if (
epoch === viewEpoch &&
identity === messageIdentityScope() &&
current.conversation?.id === conversationId
) {
if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) { if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) {
connectSocket() connectSocket()
} }
@@ -299,6 +395,11 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
if (get().isOpen || status === "disconnected") { if (get().isOpen || status === "disconnected") {
set({ status }) set({ status })
} }
if (status === "connected") {
clearMessageRecovery()
} else {
scheduleMessageRecovery()
}
}, },
onSocketChange: (socket) => { onSocketChange: (socket) => {
set({ socket }) set({ socket })
@@ -323,6 +424,9 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
subscribedConversationId = null subscribedConversationId = null
}, },
onMessage: (messageEvent) => { onMessage: (messageEvent) => {
if (socketIdentity !== messageIdentityScope()) {
return
}
let event: ImRealtimeEnvelope let event: ImRealtimeEnvelope
try { try {
event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope
@@ -414,6 +518,13 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
if (eventConversationId !== activeConversationId) { if (eventConversationId !== activeConversationId) {
return return
} }
if (
event.type === "conversation.closed" ||
payload?.status === CLOSED_IM_CONVERSATION_STATUS
) {
clearMessageRecovery()
cancelLatestSync()
}
set((state) => ({ set((state) => ({
messages: markConversationReadMessages(state.messages, payload), messages: markConversationReadMessages(state.messages, payload),
...(event.type === "conversation.closed" || ...(event.type === "conversation.closed" ||
@@ -426,19 +537,32 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
}) })
const closeSocket = (options?: { reconnect?: boolean }) => { const closeSocket = (options?: { reconnect?: boolean }) => {
recoveryEnabled = false
cancelLatestSync()
viewEpoch += 1
subscribedConversationId = null subscribedConversationId = null
realtime.disconnect({ realtime.disconnect({
reconnect: options?.reconnect ?? false, reconnect: options?.reconnect ?? false,
updateStatus: true, updateStatus: true,
}) })
clearMessageRecovery()
} }
const connectSocket = () => { const connectSocket = () => {
if (disposed) {
return
}
const activeConversationId = const activeConversationId =
get().conversation?.id || get().activeConversationId get().conversation?.id || get().activeConversationId
if (!activeConversationId) { if (!activeConversationId) {
return return
} }
const identity = messageIdentityScope()
if (socketIdentity && socketIdentity !== identity) {
closeSocket({ reconnect: false })
}
socketIdentity = identity
recoveryEnabled = true
const socket = get().socket const socket = get().socket
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
if (activeConversationId !== subscribedConversationId) { if (activeConversationId !== subscribedConversationId) {
@@ -448,9 +572,11 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
if (get().status !== "connected") { if (get().status !== "connected") {
set({ status: "connected" }) set({ status: "connected" })
} }
clearMessageRecovery()
return return
} }
if (socket && socket.readyState === WebSocket.CONNECTING) { if (socket && socket.readyState === WebSocket.CONNECTING) {
scheduleMessageRecovery()
return return
} }
void realtime.connect() void realtime.connect()
@@ -489,10 +615,21 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
setIsVisible: (isVisible: boolean) => { setIsVisible: (isVisible: boolean) => {
set({ isVisible }) set({ isVisible })
if (isVisible) {
scheduleMessageRecovery()
} else {
clearMessageRecovery()
if (latestSync?.quiet) cancelLatestSync()
}
}, },
bootstrap: () => { bootstrap: () => {
disposed = false
const token = ++bootstrapToken const token = ++bootstrapToken
viewEpoch += 1
clearMessageRecovery()
cancelLatestSync()
set({ creatingConversation: false })
if (!get().isOpen) { if (!get().isOpen) {
closeSocket({ reconnect: false }) closeSocket({ reconnect: false })
@@ -576,12 +713,10 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
})) }))
} }
await get().refreshMessages() // History and realtime are independent transports. A temporary
if (bootstrapToken !== token || !get().isOpen) { // history failure must not prevent the newly created chat connecting.
return
}
connectSocket() connectSocket()
await get().refreshMessages().catch(() => undefined)
} catch (error) { } catch (error) {
if (bootstrapToken !== token || !get().isOpen) { if (bootstrapToken !== token || !get().isOpen) {
return return
@@ -596,7 +731,24 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
void activateChat() void activateChat()
}, },
recoverConnection: () => {
if (
!get().isOpen ||
!get().isVisible ||
(typeof document !== "undefined" && document.visibilityState !== "visible")
) {
clearMessageRecovery()
if (latestSync?.quiet) cancelLatestSync()
return
}
if (recoveryEnabled) {
connectSocket()
}
},
disconnectSocket: () => { disconnectSocket: () => {
disposed = true
bootstrapToken += 1
closeSocket({ reconnect: false }) closeSocket({ reconnect: false })
}, },
@@ -618,12 +770,14 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
}, },
selectConversation: async (conversationId: number) => { selectConversation: async (conversationId: number) => {
const prevId = get().conversation?.id || get().activeConversationId
const patch = buildSelectConversationPatch(get(), conversationId) const patch = buildSelectConversationPatch(get(), conversationId)
if (!patch) { if (!patch) {
return return
} }
viewEpoch += 1
clearMessageRecovery()
cancelLatestSync()
// The patch clears messages and re-derives `conversation` together with // The patch clears messages and re-derives `conversation` together with
// the active id; otherwise refreshMessages would still read the previous // the active id; otherwise refreshMessages would still read the previous
// request and show its history under the new header. // request and show its history under the new header.
@@ -632,11 +786,7 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
activeAgentActivity: null, activeAgentActivity: null,
}) })
const socket = get().socket if (get().isOpen) {
if (socket && socket.readyState === WebSocket.OPEN) {
sendSubscription(socket, prevId, conversationId)
subscribedConversationId = conversationId
} else if (get().isOpen) {
connectSocket() connectSocket()
} }
@@ -653,9 +803,16 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
} }
set({ creatingConversation: true, error: "", activeAgentActivity: null }) set({ creatingConversation: true, error: "", activeAgentActivity: null })
const token = bootstrapToken
const identity = messageIdentityScope()
try { try {
const prevId = get().conversation?.id || get().activeConversationId
const created = await createImConversation(subject) const created = await createImConversation(subject)
if (token !== bootstrapToken || identity !== messageIdentityScope()) {
return
}
viewEpoch += 1
clearMessageRecovery()
cancelLatestSync()
set((state) => ({ set((state) => ({
creatingConversation: false, creatingConversation: false,
messages: [], messages: [],
@@ -669,18 +826,19 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
), ),
})) }))
const socket = get().socket if (get().isOpen) {
if (socket && socket.readyState === WebSocket.OPEN) {
sendSubscription(socket, prevId, created.id)
subscribedConversationId = created.id
} else if (get().isOpen) {
connectSocket() connectSocket()
} }
await get().refreshMessages() // The conversation is already created. A history outage should not
// make the dialog offer to create it again; realtime/fallback can catch up.
await get().refreshMessages().catch(() => undefined)
const latestMsgId = get().messages.at(-1)?.id ?? 0 const latestMsgId = get().messages.at(-1)?.id ?? 0
scheduleMessageReplyCheck(created.id, latestMsgId) scheduleMessageReplyCheck(created.id, latestMsgId)
} catch (error) { } catch (error) {
if (token !== bootstrapToken || identity !== messageIdentityScope()) {
return
}
set({ set({
creatingConversation: false, creatingConversation: false,
error: error:
@@ -693,6 +851,8 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
}, },
refreshMessages: async () => { refreshMessages: async () => {
const epoch = viewEpoch
const identity = messageIdentityScope()
const conversationId = get().conversation?.id const conversationId = get().conversation?.id
if (!conversationId) { if (!conversationId) {
return return
@@ -703,17 +863,35 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
conversationId, conversationId,
limit: DEFAULT_PAGE_LIMIT, limit: DEFAULT_PAGE_LIMIT,
}) })
if (get().conversation?.id !== conversationId) { if (
epoch !== viewEpoch ||
identity !== messageIdentityScope() ||
get().conversation?.id !== conversationId
) {
return return
} }
const results = ensureMessageList(page.results) const results = ensureMessageList(page.results)
set({ set((state) => {
messages: results, // The socket may already have delivered a reply newer than the HTTP
messagesCursor: cursorFromLoadedImMessages(results) || page.cursor || "", // snapshot; history loading must not erase that realtime delivery.
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, const merged = mergeImMessagesByIdAsc(state.messages, results)
return {
messages: merged,
messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "",
messagesHasMore: hasMoreAfterLatestImMessageMerge({
previousMessages: state.messages,
previousHasMore: state.messagesHasMore,
merged,
apiHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
}),
}
}) })
} catch (error) { } catch (error) {
if (get().conversation?.id !== conversationId) { if (
epoch !== viewEpoch ||
identity !== messageIdentityScope() ||
get().conversation?.id !== conversationId
) {
return return
} }
set({ set({
@@ -723,19 +901,41 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
} }
}, },
syncLatestMessages: async () => { syncLatestMessages: async (quiet = false) => {
const epoch = viewEpoch
const identity = messageIdentityScope()
const conversationId = get().conversation?.id const conversationId = get().conversation?.id
if (!conversationId) { if (!conversationId) {
return return
} }
if (latestSync) {
// A connection opening during fallback still needs a fresh catch-up
// after that older HTTP snapshot, without starting parallel requests.
latestSyncPending ||= !quiet
return
}
const controller = new AbortController()
const operation = {
controller,
timer: setTimeout(() => controller.abort(), LATEST_MESSAGE_SYNC_TIMEOUT_MS),
quiet,
}
latestSync = operation
try { try {
const page = await fetchImMessages({ const page = await fetchImMessages(
conversationId, { conversationId, limit: DEFAULT_PAGE_LIMIT },
limit: DEFAULT_PAGE_LIMIT, { signal: controller.signal }
}) )
const batch = ensureMessageList(page.results) const batch = ensureMessageList(page.results)
if (batch.length === 0) { if (
controller.signal.aborted ||
latestSync !== operation ||
epoch !== viewEpoch ||
identity !== messageIdentityScope() ||
get().conversation?.id !== conversationId ||
batch.length === 0
) {
return return
} }
set((state) => { set((state) => {
@@ -752,9 +952,31 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
} }
}) })
} catch (error) { } catch (error) {
set({ if (
error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"), controller.signal.aborted ||
}) latestSync !== operation ||
epoch !== viewEpoch ||
identity !== messageIdentityScope() ||
get().conversation?.id !== conversationId
) {
return
}
if (!quiet) {
set({
error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"),
})
}
} finally {
clearTimeout(operation.timer)
if (latestSync === operation) {
latestSync = null
if (latestSyncPending) {
latestSyncPending = false
if (!disposed && get().isOpen) {
void get().syncLatestMessages()
}
}
}
} }
}, },
@@ -950,6 +1172,7 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
}, },
closeConversation: async () => { closeConversation: async () => {
const identity = messageIdentityScope()
const conversationId = get().conversation?.id const conversationId = get().conversation?.id
if (!conversationId) { if (!conversationId) {
return return
@@ -958,17 +1181,29 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
set({ error: "", closingConversation: true }) set({ error: "", closingConversation: true })
try { try {
await closeImConversation(conversationId) await closeImConversation(conversationId)
closeSocket({ reconnect: false }) if (identity !== messageIdentityScope()) {
return
}
const isActive = get().conversation?.id === conversationId
if (isActive) {
closeSocket({ reconnect: false })
}
set((state) => ({ set((state) => ({
closingConversation: false, closingConversation: false,
status: "disconnected", ...(isActive ? { activeAgentActivity: null } : {}),
activeAgentActivity: null, ...withConversations(
...withActiveConversationPatch(state, { state,
status: CLOSED_IM_CONVERSATION_STATUS, patchConversationList(state.conversations, {
customerUnreadCount: 0, id: conversationId,
}), status: CLOSED_IM_CONVERSATION_STATUS,
customerUnreadCount: 0,
})
),
})) }))
} catch (error) { } catch (error) {
if (identity !== messageIdentityScope()) {
return
}
set({ set({
closingConversation: false, closingConversation: false,
error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"), error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"),
@@ -991,14 +1226,13 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
} }
set({ error: "", status: "connecting" }) set({ error: "", status: "connecting" })
if (get().isOpen) {
connectSocket()
}
try { try {
await get().refreshMessages() await get().refreshMessages()
if (get().isOpen) {
connectSocket()
}
} catch (error) { } catch (error) {
set({ set({
status: "disconnected",
error: error instanceof Error ? error.message : t("supportChat.refreshFailed"), error: error instanceof Error ? error.message : t("supportChat.refreshFailed"),
}) })
} }