110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
"use client";
|
|
/**
|
|
* ChatArea 消息列表区
|
|
*
|
|
* 组成(自上而下):
|
|
* - AI 披露横幅
|
|
* - 日期分隔条
|
|
* - 消息气泡(AI 左 / 用户右)
|
|
* - AI 正在回复时的加载动画气泡
|
|
*
|
|
* 滚动行为:
|
|
* - 新消息到达时自动滚到底部
|
|
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
|
*/
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import type { UiMessage } from "@/data/dto/chat";
|
|
|
|
import { AiDisclosureBanner } from "./ai-disclosure-banner";
|
|
import { DateHeader } from "./date-header";
|
|
import { LottieMessageBubble } from "./lottie-message-bubble";
|
|
import { MessageBubble } from "./message-bubble";
|
|
import styles from "./chat-area.module.css";
|
|
|
|
export interface ChatAreaProps {
|
|
messages: readonly UiMessage[];
|
|
isReplyingAI: boolean;
|
|
isGuest: boolean;
|
|
unlockingPrivateMessageId?: string | null;
|
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
|
onUnlockImagePaywall?: () => void;
|
|
}
|
|
|
|
export function ChatArea({
|
|
messages,
|
|
isReplyingAI,
|
|
unlockingPrivateMessageId,
|
|
onUnlockPrivateMessage,
|
|
onUnlockImagePaywall,
|
|
}: ChatAreaProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const prevLengthRef = useRef(messages.length);
|
|
|
|
// 新消息 → 滚到底部
|
|
useEffect(() => {
|
|
if (messages.length !== prevLengthRef.current) {
|
|
scrollRef.current?.scrollTo({
|
|
top: scrollRef.current.scrollHeight,
|
|
behavior: "smooth",
|
|
});
|
|
prevLengthRef.current = messages.length;
|
|
}
|
|
}, [messages.length]);
|
|
|
|
return (
|
|
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
|
<AiDisclosureBanner />
|
|
|
|
{renderMessagesWithDateHeaders(
|
|
messages,
|
|
unlockingPrivateMessageId,
|
|
onUnlockPrivateMessage,
|
|
onUnlockImagePaywall,
|
|
)}
|
|
|
|
{isReplyingAI && <LottieMessageBubble />}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
/** 渲染消息列表(按日期分组插入分隔条) */
|
|
function renderMessagesWithDateHeaders(
|
|
messages: readonly UiMessage[],
|
|
unlockingPrivateMessageId?: string | null,
|
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
|
onUnlockImagePaywall?: () => void,
|
|
) {
|
|
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
|
|
|
messages.forEach((m, i) => {
|
|
if (i === 0 || m.date !== messages[i - 1].date) {
|
|
items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` });
|
|
}
|
|
items.push({ type: "msg", message: m, key: `m-${i}` });
|
|
});
|
|
|
|
return items.map((item) =>
|
|
item.type === "date" ? (
|
|
<DateHeader key={item.key} date={item.date} />
|
|
) : (
|
|
<MessageBubble
|
|
key={item.key}
|
|
id={item.message.id}
|
|
content={item.message.content}
|
|
imageUrl={item.message.imageUrl}
|
|
imagePaywalled={item.message.imagePaywalled}
|
|
isFromAI={item.message.isFromAI}
|
|
lockedPrivate={item.message.lockedPrivate}
|
|
privateMessageHint={item.message.privateMessageHint}
|
|
isUnlockingPrivate={
|
|
item.message.id != null &&
|
|
item.message.id === unlockingPrivateMessageId
|
|
}
|
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
/>
|
|
),
|
|
);
|
|
}
|