Files
cozsweet-frontend-nextjs/src/app/chat/components/chat-area.tsx
T

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}
/>
),
);
}