refactor: replace custom message merging logic with centralized mergeImMessagesByIdAsc function

This commit is contained in:
mlogclub
2026-04-25 12:26:45 +08:00
parent b48aa901d6
commit e109d19ea9
4 changed files with 176 additions and 49 deletions
+22 -3
View File
@@ -78,9 +78,14 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
const contentRef = useRef<HTMLDivElement>(null)
const frameRef = useRef<number | null>(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<KefuMessageListHandle, KefuMessageList
const handleImageSettled = useCallback(() => {
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
)
}
+133
View File
@@ -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<T extends MergeableImMessage>(
a: T[],
b: T[]
): T[] {
const byId = new Map<number, T>()
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<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
const normalizedIncoming = normalizeDynamicImageContent(
existing,
normalizeDynamicImagePayload(existing, incoming)
)
return isSameImMessage(existing, normalizedIncoming) ? existing : normalizedIncoming
}
function normalizeDynamicImagePayload<T extends MergeableImMessage>(
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<T extends MergeableImMessage>(
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(
/(<img\b[^>]*\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]
})
}
+9 -26
View File
@@ -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<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
function mergeMessagesByIdAsc(
a: AgentMessage[],
b: AgentMessage[]
): AgentMessage[] {
const byId = new Map<number, AgentMessage>()
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<AgentConversationsStore>((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<AgentConversationsStore>((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<AgentConversationsStore>((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
? {
+12 -20
View File
@@ -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<number, ImMessage>()
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<KefuChatStore>((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<KefuChatStore>((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<KefuChatStore>((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,