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 contentRef = useRef<HTMLDivElement>(null)
|
||||||
const frameRef = useRef<number | null>(null)
|
const frameRef = useRef<number | null>(null)
|
||||||
const shouldStickToBottomRef = useRef(true)
|
const shouldStickToBottomRef = useRef(true)
|
||||||
|
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
|
||||||
const safeMessages = Array.isArray(messages) ? messages : []
|
const safeMessages = Array.isArray(messages) ? messages : []
|
||||||
const lastMessageId = safeMessages.at(-1)?.id
|
const lastMessageId = safeMessages.at(-1)?.id
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onNearBottomVisibleRef.current = onNearBottomVisible
|
||||||
|
}, [onNearBottomVisible])
|
||||||
|
|
||||||
const isNearBottom = useCallback(
|
const isNearBottom = useCallback(
|
||||||
(element: HTMLElement, threshold = 80) =>
|
(element: HTMLElement, threshold = 80) =>
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight <= threshold,
|
element.scrollHeight - element.scrollTop - element.clientHeight <= threshold,
|
||||||
@@ -127,9 +132,9 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
|
|||||||
const handleImageSettled = useCallback(() => {
|
const handleImageSettled = useCallback(() => {
|
||||||
if (shouldStickToBottomRef.current) {
|
if (shouldStickToBottomRef.current) {
|
||||||
scheduleScrollToBottom()
|
scheduleScrollToBottom()
|
||||||
onNearBottomVisible?.()
|
onNearBottomVisibleRef.current?.()
|
||||||
}
|
}
|
||||||
}, [onNearBottomVisible, scheduleScrollToBottom])
|
}, [scheduleScrollToBottom])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
@@ -325,7 +330,21 @@ const MessageItem = memo(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
prevProps.message === nextProps.message &&
|
isSameMessageItemRender(prevProps.message, nextProps.message) &&
|
||||||
prevProps.showTimeline === nextProps.showTimeline &&
|
prevProps.showTimeline === nextProps.showTimeline &&
|
||||||
prevProps.onImageSettled === nextProps.onImageSettled
|
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,
|
type AgentMessage,
|
||||||
} from "@/lib/api/agent"
|
} from "@/lib/api/agent"
|
||||||
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
|
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
|
||||||
|
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
|
||||||
import { summarizeIMMessage } from "@/lib/im-message"
|
import { summarizeIMMessage } from "@/lib/im-message"
|
||||||
import { generateUUID } from "@/lib/utils"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -48,20 +49,6 @@ function ensureArray<T>(value: T[] | null | undefined): T[] {
|
|||||||
return Array.isArray(value) ? value : []
|
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 {
|
function parseCursorId(cursor: string): number {
|
||||||
const n = Number.parseInt(cursor, 10)
|
const n = Number.parseInt(cursor, 10)
|
||||||
return Number.isFinite(n) && n > 0 ? n : 0
|
return Number.isFinite(n) && n > 0 ? n : 0
|
||||||
@@ -344,7 +331,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
}
|
}
|
||||||
const incoming = ensureArray(data.results)
|
const incoming = ensureArray(data.results)
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const merged = mergeMessagesByIdAsc(incoming, state.messages)
|
const merged = mergeImMessagesByIdAsc(state.messages, incoming)
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor:
|
messagesCursor:
|
||||||
@@ -376,10 +363,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const firstId = batch[0]!.id
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const preserved = state.messages.filter((m) => m.id < firstId)
|
const merged = mergeImMessagesByIdAsc(state.messages, batch)
|
||||||
const merged = mergeMessagesByIdAsc(preserved, batch)
|
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor:
|
messagesCursor:
|
||||||
@@ -425,14 +410,12 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
readingMessageId: 0,
|
readingMessageId: 0,
|
||||||
messages: current.messages.map((item) =>
|
messages: current.messages.map((item) => {
|
||||||
item.seqNo <= lastMessage.seqNo
|
if (item.seqNo > lastMessage.seqNo) {
|
||||||
? {
|
return item
|
||||||
...item,
|
}
|
||||||
agentRead: true,
|
return item.agentRead ? item : { ...item, agentRead: true }
|
||||||
}
|
}),
|
||||||
: item
|
|
||||||
),
|
|
||||||
conversations: current.conversations.map((item) =>
|
conversations: current.conversations.map((item) =>
|
||||||
item.id === conversationId
|
item.id === conversationId
|
||||||
? {
|
? {
|
||||||
|
|||||||
+12
-20
@@ -20,6 +20,7 @@ import {
|
|||||||
createImRealtimeConnection,
|
createImRealtimeConnection,
|
||||||
type ImRealtimeEnvelope,
|
type ImRealtimeEnvelope,
|
||||||
} from "@/lib/im-realtime"
|
} from "@/lib/im-realtime"
|
||||||
|
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
|
||||||
import { summarizeIMMessage } from "@/lib/im-message"
|
import { summarizeIMMessage } from "@/lib/im-message"
|
||||||
import { generateUUID } from "@/lib/utils"
|
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[] {
|
function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] {
|
||||||
return Array.isArray(value) ? value : []
|
return Array.isArray(value) ? value : []
|
||||||
}
|
}
|
||||||
@@ -424,10 +414,8 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const firstId = batch[0]!.id
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const preserved = state.messages.filter((message) => message.id < firstId)
|
const merged = mergeImMessagesByIdAsc(state.messages, batch)
|
||||||
const merged = mergeMessagesByIdAsc(preserved, batch)
|
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
||||||
@@ -470,7 +458,10 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
})
|
})
|
||||||
const results = ensureMessageList(page.results)
|
const results = ensureMessageList(page.results)
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const merged = mergeMessagesByIdAsc(results, ensureMessageList(state.messages))
|
const merged = mergeImMessagesByIdAsc(
|
||||||
|
ensureMessageList(state.messages),
|
||||||
|
results
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
||||||
@@ -510,11 +501,12 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
await markImMessageRead(conversation.id, lastMessage.id)
|
await markImMessageRead(conversation.id, lastMessage.id)
|
||||||
set((current) => ({
|
set((current) => ({
|
||||||
readingMessageId: 0,
|
readingMessageId: 0,
|
||||||
messages: current.messages.map((item) =>
|
messages: current.messages.map((item) => {
|
||||||
(item.seqNo ?? 0) <= (lastMessage.seqNo ?? 0)
|
if ((item.seqNo ?? 0) > (lastMessage.seqNo ?? 0)) {
|
||||||
? { ...item, customerRead: true }
|
return item
|
||||||
: item
|
}
|
||||||
),
|
return item.customerRead ? item : { ...item, customerRead: true }
|
||||||
|
}),
|
||||||
conversation: current.conversation
|
conversation: current.conversation
|
||||||
? {
|
? {
|
||||||
...current.conversation,
|
...current.conversation,
|
||||||
|
|||||||
Reference in New Issue
Block a user