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:
@@ -1,3 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
@@ -60,6 +61,8 @@ type ChatStatus = "connecting" | "connected" | "disconnected"
|
||||
|
||||
const DEFAULT_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
|
||||
@@ -196,12 +199,13 @@ export type SupportChatStore = {
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
setIsVisible: (isVisible: boolean) => void
|
||||
bootstrap: () => void
|
||||
recoverConnection: () => void
|
||||
disconnectSocket: () => void
|
||||
refreshConversations: () => Promise<void>
|
||||
selectConversation: (conversationId: number) => Promise<void>
|
||||
startNewConversation: (subject?: string) => Promise<void>
|
||||
refreshMessages: () => Promise<void>
|
||||
syncLatestMessages: () => Promise<void>
|
||||
syncLatestMessages: (quiet?: boolean) => Promise<void>
|
||||
loadOlderMessages: () => Promise<void>
|
||||
markConversationRead: () => Promise<void>
|
||||
handleSendMessage: (content: string) => Promise<void>
|
||||
@@ -219,6 +223,21 @@ function t(key: string) {
|
||||
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(
|
||||
state: {
|
||||
conversations: ImConversation[]
|
||||
@@ -244,6 +263,74 @@ function buildSelectConversationPatch(
|
||||
|
||||
export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
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 = (
|
||||
socket: WebSocket | null,
|
||||
@@ -275,10 +362,19 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
conversationId: number,
|
||||
sentMessageId: number
|
||||
) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const epoch = viewEpoch
|
||||
const identity = messageIdentityScope()
|
||||
for (const delay of [1500, 3500, 7000]) {
|
||||
setTimeout(() => {
|
||||
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)) {
|
||||
connectSocket()
|
||||
}
|
||||
@@ -299,6 +395,11 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
if (get().isOpen || status === "disconnected") {
|
||||
set({ status })
|
||||
}
|
||||
if (status === "connected") {
|
||||
clearMessageRecovery()
|
||||
} else {
|
||||
scheduleMessageRecovery()
|
||||
}
|
||||
},
|
||||
onSocketChange: (socket) => {
|
||||
set({ socket })
|
||||
@@ -323,6 +424,9 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
subscribedConversationId = null
|
||||
},
|
||||
onMessage: (messageEvent) => {
|
||||
if (socketIdentity !== messageIdentityScope()) {
|
||||
return
|
||||
}
|
||||
let event: ImRealtimeEnvelope
|
||||
try {
|
||||
event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope
|
||||
@@ -414,6 +518,13 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
if (eventConversationId !== activeConversationId) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type === "conversation.closed" ||
|
||||
payload?.status === CLOSED_IM_CONVERSATION_STATUS
|
||||
) {
|
||||
clearMessageRecovery()
|
||||
cancelLatestSync()
|
||||
}
|
||||
set((state) => ({
|
||||
messages: markConversationReadMessages(state.messages, payload),
|
||||
...(event.type === "conversation.closed" ||
|
||||
@@ -426,19 +537,32 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
})
|
||||
|
||||
const closeSocket = (options?: { reconnect?: boolean }) => {
|
||||
recoveryEnabled = false
|
||||
cancelLatestSync()
|
||||
viewEpoch += 1
|
||||
subscribedConversationId = null
|
||||
realtime.disconnect({
|
||||
reconnect: options?.reconnect ?? false,
|
||||
updateStatus: true,
|
||||
})
|
||||
clearMessageRecovery()
|
||||
}
|
||||
|
||||
const connectSocket = () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const activeConversationId =
|
||||
get().conversation?.id || get().activeConversationId
|
||||
if (!activeConversationId) {
|
||||
return
|
||||
}
|
||||
const identity = messageIdentityScope()
|
||||
if (socketIdentity && socketIdentity !== identity) {
|
||||
closeSocket({ reconnect: false })
|
||||
}
|
||||
socketIdentity = identity
|
||||
recoveryEnabled = true
|
||||
const socket = get().socket
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
if (activeConversationId !== subscribedConversationId) {
|
||||
@@ -448,9 +572,11 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
if (get().status !== "connected") {
|
||||
set({ status: "connected" })
|
||||
}
|
||||
clearMessageRecovery()
|
||||
return
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.CONNECTING) {
|
||||
scheduleMessageRecovery()
|
||||
return
|
||||
}
|
||||
void realtime.connect()
|
||||
@@ -489,10 +615,21 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
|
||||
setIsVisible: (isVisible: boolean) => {
|
||||
set({ isVisible })
|
||||
if (isVisible) {
|
||||
scheduleMessageRecovery()
|
||||
} else {
|
||||
clearMessageRecovery()
|
||||
if (latestSync?.quiet) cancelLatestSync()
|
||||
}
|
||||
},
|
||||
|
||||
bootstrap: () => {
|
||||
disposed = false
|
||||
const token = ++bootstrapToken
|
||||
viewEpoch += 1
|
||||
clearMessageRecovery()
|
||||
cancelLatestSync()
|
||||
set({ creatingConversation: false })
|
||||
|
||||
if (!get().isOpen) {
|
||||
closeSocket({ reconnect: false })
|
||||
@@ -576,12 +713,10 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
}))
|
||||
}
|
||||
|
||||
await get().refreshMessages()
|
||||
if (bootstrapToken !== token || !get().isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
// History and realtime are independent transports. A temporary
|
||||
// history failure must not prevent the newly created chat connecting.
|
||||
connectSocket()
|
||||
await get().refreshMessages().catch(() => undefined)
|
||||
} catch (error) {
|
||||
if (bootstrapToken !== token || !get().isOpen) {
|
||||
return
|
||||
@@ -596,7 +731,24 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
void activateChat()
|
||||
},
|
||||
|
||||
recoverConnection: () => {
|
||||
if (
|
||||
!get().isOpen ||
|
||||
!get().isVisible ||
|
||||
(typeof document !== "undefined" && document.visibilityState !== "visible")
|
||||
) {
|
||||
clearMessageRecovery()
|
||||
if (latestSync?.quiet) cancelLatestSync()
|
||||
return
|
||||
}
|
||||
if (recoveryEnabled) {
|
||||
connectSocket()
|
||||
}
|
||||
},
|
||||
|
||||
disconnectSocket: () => {
|
||||
disposed = true
|
||||
bootstrapToken += 1
|
||||
closeSocket({ reconnect: false })
|
||||
},
|
||||
|
||||
@@ -618,12 +770,14 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
},
|
||||
|
||||
selectConversation: async (conversationId: number) => {
|
||||
const prevId = get().conversation?.id || get().activeConversationId
|
||||
const patch = buildSelectConversationPatch(get(), conversationId)
|
||||
if (!patch) {
|
||||
return
|
||||
}
|
||||
|
||||
viewEpoch += 1
|
||||
clearMessageRecovery()
|
||||
cancelLatestSync()
|
||||
// The patch clears messages and re-derives `conversation` together with
|
||||
// the active id; otherwise refreshMessages would still read the previous
|
||||
// request and show its history under the new header.
|
||||
@@ -632,11 +786,7 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
activeAgentActivity: null,
|
||||
})
|
||||
|
||||
const socket = get().socket
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
sendSubscription(socket, prevId, conversationId)
|
||||
subscribedConversationId = conversationId
|
||||
} else if (get().isOpen) {
|
||||
if (get().isOpen) {
|
||||
connectSocket()
|
||||
}
|
||||
|
||||
@@ -653,9 +803,16 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
}
|
||||
|
||||
set({ creatingConversation: true, error: "", activeAgentActivity: null })
|
||||
const token = bootstrapToken
|
||||
const identity = messageIdentityScope()
|
||||
try {
|
||||
const prevId = get().conversation?.id || get().activeConversationId
|
||||
const created = await createImConversation(subject)
|
||||
if (token !== bootstrapToken || identity !== messageIdentityScope()) {
|
||||
return
|
||||
}
|
||||
viewEpoch += 1
|
||||
clearMessageRecovery()
|
||||
cancelLatestSync()
|
||||
set((state) => ({
|
||||
creatingConversation: false,
|
||||
messages: [],
|
||||
@@ -669,18 +826,19 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
),
|
||||
}))
|
||||
|
||||
const socket = get().socket
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
sendSubscription(socket, prevId, created.id)
|
||||
subscribedConversationId = created.id
|
||||
} else if (get().isOpen) {
|
||||
if (get().isOpen) {
|
||||
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
|
||||
scheduleMessageReplyCheck(created.id, latestMsgId)
|
||||
} catch (error) {
|
||||
if (token !== bootstrapToken || identity !== messageIdentityScope()) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
creatingConversation: false,
|
||||
error:
|
||||
@@ -693,6 +851,8 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
},
|
||||
|
||||
refreshMessages: async () => {
|
||||
const epoch = viewEpoch
|
||||
const identity = messageIdentityScope()
|
||||
const conversationId = get().conversation?.id
|
||||
if (!conversationId) {
|
||||
return
|
||||
@@ -703,17 +863,35 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
conversationId,
|
||||
limit: DEFAULT_PAGE_LIMIT,
|
||||
})
|
||||
if (get().conversation?.id !== conversationId) {
|
||||
if (
|
||||
epoch !== viewEpoch ||
|
||||
identity !== messageIdentityScope() ||
|
||||
get().conversation?.id !== conversationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const results = ensureMessageList(page.results)
|
||||
set({
|
||||
messages: results,
|
||||
messagesCursor: cursorFromLoadedImMessages(results) || page.cursor || "",
|
||||
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
||||
set((state) => {
|
||||
// The socket may already have delivered a reply newer than the HTTP
|
||||
// snapshot; history loading must not erase that realtime delivery.
|
||||
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) {
|
||||
if (get().conversation?.id !== conversationId) {
|
||||
if (
|
||||
epoch !== viewEpoch ||
|
||||
identity !== messageIdentityScope() ||
|
||||
get().conversation?.id !== conversationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
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
|
||||
if (!conversationId) {
|
||||
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 {
|
||||
const page = await fetchImMessages({
|
||||
conversationId,
|
||||
limit: DEFAULT_PAGE_LIMIT,
|
||||
})
|
||||
const page = await fetchImMessages(
|
||||
{ conversationId, limit: DEFAULT_PAGE_LIMIT },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
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
|
||||
}
|
||||
set((state) => {
|
||||
@@ -752,9 +952,31 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"),
|
||||
})
|
||||
if (
|
||||
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 () => {
|
||||
const identity = messageIdentityScope()
|
||||
const conversationId = get().conversation?.id
|
||||
if (!conversationId) {
|
||||
return
|
||||
@@ -958,17 +1181,29 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
set({ error: "", closingConversation: true })
|
||||
try {
|
||||
await closeImConversation(conversationId)
|
||||
closeSocket({ reconnect: false })
|
||||
if (identity !== messageIdentityScope()) {
|
||||
return
|
||||
}
|
||||
const isActive = get().conversation?.id === conversationId
|
||||
if (isActive) {
|
||||
closeSocket({ reconnect: false })
|
||||
}
|
||||
set((state) => ({
|
||||
closingConversation: false,
|
||||
status: "disconnected",
|
||||
activeAgentActivity: null,
|
||||
...withActiveConversationPatch(state, {
|
||||
status: CLOSED_IM_CONVERSATION_STATUS,
|
||||
customerUnreadCount: 0,
|
||||
}),
|
||||
...(isActive ? { activeAgentActivity: null } : {}),
|
||||
...withConversations(
|
||||
state,
|
||||
patchConversationList(state.conversations, {
|
||||
id: conversationId,
|
||||
status: CLOSED_IM_CONVERSATION_STATUS,
|
||||
customerUnreadCount: 0,
|
||||
})
|
||||
),
|
||||
}))
|
||||
} catch (error) {
|
||||
if (identity !== messageIdentityScope()) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
closingConversation: false,
|
||||
error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"),
|
||||
@@ -991,14 +1226,13 @@ export const useSupportChatStore = create<SupportChatStore>((set, get) => {
|
||||
}
|
||||
|
||||
set({ error: "", status: "connecting" })
|
||||
if (get().isOpen) {
|
||||
connectSocket()
|
||||
}
|
||||
try {
|
||||
await get().refreshMessages()
|
||||
if (get().isOpen) {
|
||||
connectSocket()
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
status: "disconnected",
|
||||
error: error instanceof Error ? error.message : t("supportChat.refreshFailed"),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user