310 lines
8.6 KiB
TypeScript
310 lines
8.6 KiB
TypeScript
"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<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(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 (
|
|
<main
|
|
ref={scrollRef}
|
|
className={styles.area}
|
|
aria-label="Chat messages"
|
|
data-pulling={isPulling ? "true" : "false"}
|
|
style={pullStyle}
|
|
{...touchHandlers}
|
|
onScroll={(event) => {
|
|
shouldStickToBottomRef.current = isNearBottom(event.currentTarget);
|
|
}}
|
|
>
|
|
<div
|
|
className={styles.pullIndicator}
|
|
aria-hidden={!showPullIndicator}
|
|
>
|
|
<LoaderCircle
|
|
size={17}
|
|
aria-hidden="true"
|
|
data-spinning={isRefreshing ? "true" : "false"}
|
|
/>
|
|
<span>
|
|
{isRefreshing
|
|
? "Loading earlier messages..."
|
|
: isReleaseReady
|
|
? "Release to load earlier messages"
|
|
: "Pull down for earlier messages"}
|
|
</span>
|
|
</div>
|
|
<div ref={contentRef} className={styles.content}>
|
|
<AiDisclosureBanner />
|
|
|
|
{renderMessagesWithDateHeaders(
|
|
messages,
|
|
getMessageKey,
|
|
isUnlockingMessage,
|
|
unlockingMessageId,
|
|
onUnlockPrivateMessage,
|
|
onUnlockVoiceMessage,
|
|
onUnlockImageMessage,
|
|
onOpenImage,
|
|
)}
|
|
|
|
{isReplyingAI ? (
|
|
<Suspense fallback={null}>
|
|
<ReplyingAnimation />
|
|
</Suspense>
|
|
) : null}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
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" ? (
|
|
<DateHeader key={item.key} date={item.date} />
|
|
) : (
|
|
<MessageBubble
|
|
key={item.key}
|
|
messageId={item.message.id}
|
|
content={item.message.content}
|
|
imageUrl={item.message.imageUrl}
|
|
imagePaywalled={item.message.imagePaywalled}
|
|
audioUrl={item.message.audioUrl}
|
|
isFromAI={item.message.isFromAI}
|
|
locked={item.message.locked}
|
|
lockReason={item.message.lockReason}
|
|
lockedPrivate={item.message.lockedPrivate}
|
|
privateMessageHint={item.message.privateMessageHint}
|
|
isUnlockingMessage={
|
|
isUnlockingMessage === true &&
|
|
item.message.id === unlockingMessageId
|
|
}
|
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
|
onUnlockImageMessage={onUnlockImageMessage}
|
|
onOpenImage={onOpenImage}
|
|
/>
|
|
),
|
|
);
|
|
}
|