"use client"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, } from "react"; import Image from "next/image"; import { MessageHTML } from "@/components/im/message-html"; import { useImageLightbox } from "@/components/image-lightbox"; import { renderMessageHTML } from "@/lib/services/message-asset"; import type { WidgetMessage } from "@/lib/services/types"; import { cn, formatDateTime } from "@/lib/utils"; type MessageListProps = { messages: WidgetMessage[]; onNearBottomVisible?: () => void; hasMoreOlder?: boolean; loadingOlder?: boolean; onLoadOlder?: () => Promise; }; export type MessageListHandle = { 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 now = new Date(); const currentDayKey = getDayKey(value); const todayDayKey = getDayKey(now.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 MessageList = forwardRef( function MessageList( { 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 lastMessageId = messages.at(-1)?.id; 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(); onNearBottomVisible?.(); } }, [onNearBottomVisible, 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(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 c = containerRef.current; if (!c) { return; } c.scrollTop = c.scrollHeight - anchor.height + anchor.top; }); }); }, [hasMoreOlder, loadingOlder, onLoadOlder]); return (
{hasMoreOlder && onLoadOlder ? (
) : null} {/* */} {/* {messages.length === 0 ? (
开始发送第一条消息后,会在这里保留完整会话记录。
) : null} */} {messages.map((message, index) => { const previousMessage = index > 0 ? messages[index - 1] : null; const showTimeline = index === 0 || getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt); return ( ); })}
); }, ); type MessageItemProps = { message: WidgetMessage; showTimeline: boolean; onImageSettled: () => void; }; const MessageItem = memo( function MessageItem({ message, showTimeline, onImageSettled, }: MessageItemProps) { const { open: openImageLightbox } = useImageLightbox(); const isCustomer = message.senderType === "customer"; const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服"; const agentAvatarSrc = !isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined; const htmlContent = buildMessageHTML(message); return (
{showTimeline ? (
{getTimelineLabel(message.sentAt)}
) : null}
{!isCustomer && agentAvatarSrc ? ( ) : null}
{senderName} {formatDateTime(message.sentAt)} {isCustomer ? ( {message.agentRead ? "客服已读" : "客服未读"} ) : null}
); }, (prevProps, nextProps) => prevProps.message === nextProps.message && prevProps.showTimeline === nextProps.showTimeline && prevProps.onImageSettled === nextProps.onImageSettled, ); function buildMessageHTML(message: WidgetMessage) { return renderMessageHTML(message); }