"use client" import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, } from "react" import { ImMessageHTML } from "@/components/im-message-html" import { useImageLightbox } from "@/components/image-lightbox" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import type { ImMessage } from "@/lib/api/im" import { renderIMMessageHTML } from "@/lib/im-message" import { cn, formatDateTime } from "@/lib/utils" type KefuMessageListProps = { messages?: ImMessage[] | null onNearBottomVisible?: () => void hasMoreOlder?: boolean loadingOlder?: boolean onLoadOlder?: () => Promise } export type KefuMessageListHandle = { scrollToBottom: () => void } function getDayKey(value?: string) { if (!value) { return "unknown" } const date = new Date(value) if (Number.isNaN(date.getTime())) { return value.slice(0, 10) } return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( date.getDate() ).padStart(2, "0")}` } function getTimelineLabel(value?: string) { if (!value) { return "刚刚" } const date = new Date(value) if (Number.isNaN(date.getTime())) { return value } const currentDayKey = getDayKey(value) const todayDayKey = getDayKey(new Date().toISOString()) const timeText = `${String(date.getHours()).padStart(2, "0")}:${String( date.getMinutes() ).padStart(2, "0")}` if (currentDayKey === todayDayKey) { return `今天 ${timeText}` } return `${currentDayKey} ${timeText}` } export const KefuMessageList = forwardRef( function KefuMessageList( { messages, onNearBottomVisible, hasMoreOlder = false, loadingOlder = false, onLoadOlder, }, ref ) { const containerRef = useRef(null) const contentRef = useRef(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, [] ) const scrollToBottom = useCallback(() => { const container = containerRef.current if (!container) { return } container.scrollTop = container.scrollHeight }, []) const scheduleScrollToBottom = useCallback( (attempts = 4) => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current) } const run = (remaining: number, previousHeight = -1) => { frameRef.current = requestAnimationFrame(() => { const container = containerRef.current if (!container) { frameRef.current = null return } const currentHeight = container.scrollHeight scrollToBottom() if (remaining > 1 && currentHeight !== previousHeight) { run(remaining - 1, currentHeight) return } frameRef.current = null }) } run(attempts) }, [scrollToBottom] ) const handleImageSettled = useCallback(() => { if (shouldStickToBottomRef.current) { scheduleScrollToBottom() onNearBottomVisibleRef.current?.() } }, [scheduleScrollToBottom]) useImperativeHandle(ref, () => ({ scrollToBottom, })) useLayoutEffect(() => { shouldStickToBottomRef.current = true scheduleScrollToBottom() return () => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current) frameRef.current = null } } }, [lastMessageId, scheduleScrollToBottom]) useEffect(() => { const container = containerRef.current const content = contentRef.current if (!container || !content) { return } const handleScroll = () => { shouldStickToBottomRef.current = isNearBottom(container) if (shouldStickToBottomRef.current) { onNearBottomVisible?.() } } const resizeObserver = new ResizeObserver(() => { if (shouldStickToBottomRef.current) { scheduleScrollToBottom() } }) handleScroll() container.addEventListener("scroll", handleScroll) resizeObserver.observe(container) resizeObserver.observe(content) scrollToBottom() return () => { container.removeEventListener("scroll", handleScroll) resizeObserver.disconnect() } }, [isNearBottom, onNearBottomVisible, scheduleScrollToBottom, scrollToBottom]) const handleLoadOlder = useCallback(async () => { if (!onLoadOlder || loadingOlder || !hasMoreOlder) { return } const container = containerRef.current if (!container) { return } const anchor = { height: container.scrollHeight, top: container.scrollTop, } try { await onLoadOlder() } catch { return } requestAnimationFrame(() => { requestAnimationFrame(() => { const current = containerRef.current if (!current) { return } current.scrollTop = current.scrollHeight - anchor.height + anchor.top }) }) }, [hasMoreOlder, loadingOlder, onLoadOlder]) return (
{hasMoreOlder && onLoadOlder ? (
) : null} {safeMessages.map((message, index) => { const previousMessage = index > 0 ? safeMessages[index - 1] : null const showTimeline = index === 0 || getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt) return ( ) })}
) } ) type MessageItemProps = { message: ImMessage showTimeline: boolean onImageSettled: () => void } const MessageItem = memo( function MessageItem({ message, showTimeline, onImageSettled }: MessageItemProps) { const { open } = useImageLightbox() const isCustomer = message.senderType === "customer" const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服" const avatarSrc = !isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined const htmlContent = renderIMMessageHTML(message) const fallbackName = senderName.slice(0, 1).toUpperCase() return (
{showTimeline ? (
{getTimelineLabel(message.sentAt)}
) : null}
{!isCustomer ? ( {avatarSrc ? : null} {fallbackName || "客"} ) : null}
{senderName} {formatDateTime(message.sentAt)} {isCustomer ? ( {message.agentRead ? "客服已读" : "客服未读"} ) : null}
) }, (prevProps, nextProps) => 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 ) }