"use client"; /** * ChatArea 消息列表区 * * 组成(自上而下): * - AI 披露横幅 * - 日期分隔条 * - 消息气泡(AI 左 / 用户右) * - AI 正在回复时的加载动画气泡 * * 滚动行为: * - 新消息到达时自动滚到底部 * - 用户向上滚动时不强制拉回(保持阅读上下文) */ import { lazy, Suspense, type CSSProperties, useEffect, useLayoutEffect, useMemo, useRef, } from "react"; import { LoaderCircle } from "lucide-react"; import type { UiMessage } from "@/data/dto/chat"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { buildChatRenderItems, createChatMessageKeyResolver, type ChatMessageKeyResolver, } from "../chat-render-items"; import { AiDisclosureBanner } from "./ai-disclosure-banner"; import { DateHeader } from "./date-header"; import { MessageBubble } from "./message-bubble"; import styles from "./chat-area.module.css"; const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96; const CHAT_PULL_LOADING_OFFSET = 46; const ReplyingAnimation = lazy(() => import("./lottie-message-bubble").then((module) => ({ default: module.LottieMessageBubble, })), ); export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; scrollToBottomSignal?: number; initialScrollReady?: boolean; canLoadMoreHistory?: boolean; isLoadingMoreHistory?: boolean; isUnlockingMessage?: boolean; unlockingMessageId?: string | null; onUnlockPrivateMessage?: (messageId: string) => void; onUnlockVoiceMessage?: (messageId: string) => void; onUnlockImageMessage?: (messageId: string) => void; onOpenImage?: (messageId: string) => void; onLoadMoreHistory?: () => void; } export function ChatArea({ messages, isReplyingAI, scrollToBottomSignal = 0, initialScrollReady = true, canLoadMoreHistory = false, isLoadingMoreHistory = false, isUnlockingMessage, unlockingMessageId, onUnlockPrivateMessage, onUnlockVoiceMessage, onUnlockImageMessage, onOpenImage, onLoadMoreHistory, }: ChatAreaProps) { const scrollRef = useRef(null); const contentRef = useRef(null); const initialScrollSettledRef = useRef(false); const shouldStickToBottomRef = useRef(true); const previousScrollToBottomSignalRef = useRef(scrollToBottomSignal); const loadMoreAnchorRef = useRef<{ scrollHeight: number; scrollTop: number; } | null>(null); const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []); const { isPulling, isRefreshing, isReleaseReady, pullDistance, touchHandlers, } = usePullToRefresh( (scrollNode) => { if (!onLoadMoreHistory) return; loadMoreAnchorRef.current = { scrollHeight: scrollNode.scrollHeight, scrollTop: scrollNode.scrollTop, }; shouldStickToBottomRef.current = false; onLoadMoreHistory(); }, { disabled: !initialScrollReady || !canLoadMoreHistory || !onLoadMoreHistory, refreshing: isLoadingMoreHistory, }, ); const pullOffset = isRefreshing ? CHAT_PULL_LOADING_OFFSET : pullDistance; const pullStyle = { "--chat-pull-offset": `${pullOffset}px`, } as CSSProperties; const showPullIndicator = pullOffset > 0; useLayoutEffect(() => { if (!initialScrollReady) { initialScrollSettledRef.current = false; previousScrollToBottomSignalRef.current = scrollToBottomSignal; return; } const scrollNode = scrollRef.current; if (!scrollNode) return; const shouldForceScrollToBottom = previousScrollToBottomSignalRef.current !== scrollToBottomSignal; previousScrollToBottomSignalRef.current = scrollToBottomSignal; if (shouldForceScrollToBottom) { loadMoreAnchorRef.current = null; initialScrollSettledRef.current = true; shouldStickToBottomRef.current = true; scrollToBottom(scrollNode); const frameId = requestAnimationFrame(() => { if ( scrollRef.current === scrollNode && shouldStickToBottomRef.current ) { scrollToBottom(scrollNode); } }); return () => cancelAnimationFrame(frameId); } if (loadMoreAnchorRef.current && !isLoadingMoreHistory) { const anchor = loadMoreAnchorRef.current; loadMoreAnchorRef.current = null; scrollNode.scrollTop = Math.max( 0, anchor.scrollTop + scrollNode.scrollHeight - anchor.scrollHeight, ); return; } if (!initialScrollSettledRef.current) { initialScrollSettledRef.current = true; shouldStickToBottomRef.current = true; } if (!shouldStickToBottomRef.current) return; scrollToBottom(scrollNode); const frameId = requestAnimationFrame(() => { if ( scrollRef.current === scrollNode && shouldStickToBottomRef.current ) { scrollToBottom(scrollNode); } }); return () => cancelAnimationFrame(frameId); }, [ initialScrollReady, isLoadingMoreHistory, isReplyingAI, messages, scrollToBottomSignal, ]); useEffect(() => { const scrollNode = scrollRef.current; const contentNode = contentRef.current; if (!scrollNode || !contentNode || typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver(() => { if ( initialScrollSettledRef.current && shouldStickToBottomRef.current ) { scrollToBottom(scrollNode); } }); observer.observe(scrollNode); observer.observe(contentNode); return () => observer.disconnect(); }, []); return (
{ shouldStickToBottomRef.current = isNearBottom(event.currentTarget); }} >
{renderMessagesWithDateHeaders( messages, getMessageKey, isUnlockingMessage, unlockingMessageId, onUnlockPrivateMessage, onUnlockVoiceMessage, onUnlockImageMessage, onOpenImage, )} {isReplyingAI ? ( ) : null}
); } function scrollToBottom(scrollNode: HTMLElement): void { scrollNode.scrollTop = Math.max( 0, scrollNode.scrollHeight - scrollNode.clientHeight, ); } function isNearBottom(scrollNode: HTMLElement): boolean { const distanceFromBottom = scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight; return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD; } /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], getMessageKey: ChatMessageKeyResolver, isUnlockingMessage?: boolean, unlockingMessageId?: string | null, onUnlockPrivateMessage?: (messageId: string) => void, onUnlockVoiceMessage?: (messageId: string) => void, onUnlockImageMessage?: (messageId: string) => void, onOpenImage?: (messageId: string) => void, ) { return buildChatRenderItems(messages, getMessageKey).map((item) => item.type === "date" ? ( ) : ( ), ); }