diff --git a/web/components/kefu/message-list.tsx b/web/components/kefu/message-list.tsx index 2025c07..b2e7d77 100644 --- a/web/components/kefu/message-list.tsx +++ b/web/components/kefu/message-list.tsx @@ -78,9 +78,14 @@ export const KefuMessageList = forwardRef(null) const frameRef = useRef(null) const shouldStickToBottomRef = useRef(true) + const onNearBottomVisibleRef = useRef(onNearBottomVisible) const safeMessages = Array.isArray(messages) ? messages : [] const lastMessageId = safeMessages.at(-1)?.id + useEffect(() => { + onNearBottomVisibleRef.current = onNearBottomVisible + }, [onNearBottomVisible]) + const isNearBottom = useCallback( (element: HTMLElement, threshold = 80) => element.scrollHeight - element.scrollTop - element.clientHeight <= threshold, @@ -127,9 +132,9 @@ export const KefuMessageList = forwardRef { if (shouldStickToBottomRef.current) { scheduleScrollToBottom() - onNearBottomVisible?.() + onNearBottomVisibleRef.current?.() } - }, [onNearBottomVisible, scheduleScrollToBottom]) + }, [scheduleScrollToBottom]) useImperativeHandle(ref, () => ({ scrollToBottom, @@ -325,7 +330,21 @@ const MessageItem = memo( ) }, (prevProps, nextProps) => - prevProps.message === nextProps.message && + isSameMessageItemRender(prevProps.message, nextProps.message) && prevProps.showTimeline === nextProps.showTimeline && prevProps.onImageSettled === nextProps.onImageSettled ) + +function isSameMessageItemRender(prev: ImMessage, next: ImMessage) { + return ( + prev.id === next.id && + prev.senderType === next.senderType && + prev.senderName === next.senderName && + prev.senderAvatar === next.senderAvatar && + prev.messageType === next.messageType && + prev.content === next.content && + prev.payload === next.payload && + prev.sentAt === next.sentAt && + prev.agentRead === next.agentRead + ) +} diff --git a/web/lib/im-message-merge.ts b/web/lib/im-message-merge.ts new file mode 100644 index 0000000..cd56247 --- /dev/null +++ b/web/lib/im-message-merge.ts @@ -0,0 +1,133 @@ +import { parseMessageAssetPayload } from "@/lib/im-message" + +export type MergeableImMessage = { + id: number + conversationId: number + clientMsgId?: string + senderType: string + senderId: number + senderName?: string + senderAvatar?: string + messageType: string + content: string + payload?: string + seqNo: number + sendStatus: number + sentAt?: string + deliveredAt?: string + readAt?: string + customerRead: boolean + customerReadAt?: string + agentRead: boolean + agentReadAt?: string + recalledAt?: string + quotedMessageId?: number +} + +export function mergeImMessagesByIdAsc( + a: T[], + b: T[] +): T[] { + const byId = new Map() + for (const message of a) { + byId.set(message.id, message) + } + for (const message of b) { + const existing = byId.get(message.id) + byId.set(message.id, existing ? mergeImMessage(existing, message) : message) + } + return Array.from(byId.values()).sort((x, y) => x.id - y.id) +} + +export function mergeImMessage( + existing: T, + incoming: T +): T { + const normalizedIncoming = normalizeDynamicImageContent( + existing, + normalizeDynamicImagePayload(existing, incoming) + ) + return isSameImMessage(existing, normalizedIncoming) ? existing : normalizedIncoming +} + +function normalizeDynamicImagePayload( + existing: T, + incoming: T +): T { + if ( + existing.messageType !== "image" || + incoming.messageType !== "image" || + !existing.payload || + !incoming.payload + ) { + return incoming + } + + const existingAsset = parseMessageAssetPayload(existing.payload) + const incomingAsset = parseMessageAssetPayload(incoming.payload) + if ( + !existingAsset?.assetId || + existingAsset.assetId !== incomingAsset?.assetId || + existing.payload === incoming.payload + ) { + return incoming + } + + return { + ...incoming, + payload: existing.payload, + } +} + +function normalizeDynamicImageContent( + existing: T, + incoming: T +): T { + if ( + existing.content === incoming.content || + !existing.content.includes("data-asset-id") || + !incoming.content.includes("data-asset-id") + ) { + return incoming + } + + if (getStableHTMLContentKey(existing.content) !== getStableHTMLContentKey(incoming.content)) { + return incoming + } + + return { + ...incoming, + content: existing.content, + } +} + +function getStableHTMLContentKey(html: string): string { + if (typeof document === "undefined") { + return html.replace( + /(]*\bdata-asset-id=(["'])[^"']+\2[^>]*?)\s+(?:src|srcset)=(["'])[^"']*\3/gi, + "$1" + ) + } + + const template = document.createElement("template") + template.innerHTML = html + for (const image of Array.from(template.content.querySelectorAll("img"))) { + if (image.getAttribute("data-asset-id")) { + image.removeAttribute("src") + image.removeAttribute("srcset") + } + } + return template.innerHTML +} + +function isSameImMessage(a: MergeableImMessage, b: MergeableImMessage): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => { + const field = key as keyof MergeableImMessage + return Object.prototype.hasOwnProperty.call(b, key) && a[field] === b[field] + }) +} diff --git a/web/lib/stores/agent-conversations.ts b/web/lib/stores/agent-conversations.ts index bbd30ba..7bdb8fa 100644 --- a/web/lib/stores/agent-conversations.ts +++ b/web/lib/stores/agent-conversations.ts @@ -15,6 +15,7 @@ import { type AgentMessage, } from "@/lib/api/agent" import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status" +import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge" import { summarizeIMMessage } from "@/lib/im-message" import { generateUUID } from "@/lib/utils" @@ -48,20 +49,6 @@ function ensureArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : [] } -function mergeMessagesByIdAsc( - a: AgentMessage[], - b: AgentMessage[] -): AgentMessage[] { - const byId = new Map() - for (const m of a) { - byId.set(m.id, m) - } - for (const m of b) { - byId.set(m.id, m) - } - return Array.from(byId.values()).sort((x, y) => x.id - y.id) -} - function parseCursorId(cursor: string): number { const n = Number.parseInt(cursor, 10) return Number.isFinite(n) && n > 0 ? n : 0 @@ -344,7 +331,7 @@ export const useAgentConversationsStore = create((set, } const incoming = ensureArray(data.results) set((state) => { - const merged = mergeMessagesByIdAsc(incoming, state.messages) + const merged = mergeImMessagesByIdAsc(state.messages, incoming) return { messages: merged, messagesCursor: @@ -376,10 +363,8 @@ export const useAgentConversationsStore = create((set, if (batch.length === 0) { return } - const firstId = batch[0]!.id set((state) => { - const preserved = state.messages.filter((m) => m.id < firstId) - const merged = mergeMessagesByIdAsc(preserved, batch) + const merged = mergeImMessagesByIdAsc(state.messages, batch) return { messages: merged, messagesCursor: @@ -425,14 +410,12 @@ export const useAgentConversationsStore = create((set, } return { readingMessageId: 0, - messages: current.messages.map((item) => - item.seqNo <= lastMessage.seqNo - ? { - ...item, - agentRead: true, - } - : item - ), + messages: current.messages.map((item) => { + if (item.seqNo > lastMessage.seqNo) { + return item + } + return item.agentRead ? item : { ...item, agentRead: true } + }), conversations: current.conversations.map((item) => item.id === conversationId ? { diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts index 33185da..032c63e 100644 --- a/web/lib/stores/kefu-chat.ts +++ b/web/lib/stores/kefu-chat.ts @@ -20,6 +20,7 @@ import { createImRealtimeConnection, type ImRealtimeEnvelope, } from "@/lib/im-realtime" +import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge" import { summarizeIMMessage } from "@/lib/im-message" import { generateUUID } from "@/lib/utils" @@ -61,17 +62,6 @@ function showNotification(title: string, body: string, onClick?: () => void) { } } -function mergeMessagesByIdAsc(a: ImMessage[], b: ImMessage[]): ImMessage[] { - const byId = new Map() - for (const message of a) { - byId.set(message.id, message) - } - for (const message of b) { - byId.set(message.id, message) - } - return Array.from(byId.values()).sort((x, y) => x.id - y.id) -} - function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] { return Array.isArray(value) ? value : [] } @@ -424,10 +414,8 @@ export const useKefuChatStore = create((set, get) => { if (batch.length === 0) { return } - const firstId = batch[0]!.id set((state) => { - const preserved = state.messages.filter((message) => message.id < firstId) - const merged = mergeMessagesByIdAsc(preserved, batch) + const merged = mergeImMessagesByIdAsc(state.messages, batch) return { messages: merged, messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "", @@ -470,7 +458,10 @@ export const useKefuChatStore = create((set, get) => { }) const results = ensureMessageList(page.results) set((state) => { - const merged = mergeMessagesByIdAsc(results, ensureMessageList(state.messages)) + const merged = mergeImMessagesByIdAsc( + ensureMessageList(state.messages), + results + ) return { messages: merged, messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "", @@ -510,11 +501,12 @@ export const useKefuChatStore = create((set, get) => { await markImMessageRead(conversation.id, lastMessage.id) set((current) => ({ readingMessageId: 0, - messages: current.messages.map((item) => - (item.seqNo ?? 0) <= (lastMessage.seqNo ?? 0) - ? { ...item, customerRead: true } - : item - ), + messages: current.messages.map((item) => { + if ((item.seqNo ?? 0) > (lastMessage.seqNo ?? 0)) { + return item + } + return item.customerRead ? item : { ...item, customerRead: true } + }), conversation: current.conversation ? { ...current.conversation,