refactor: replace custom message merging logic with centralized mergeImMessagesByIdAsc function
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user