feat(chat): port chat widget components from Dart to React
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
/**
|
||||
* ChatArea 消息列表区
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/widgets/chat_area.dart(153 行)
|
||||
*
|
||||
* 组成(自上而下):
|
||||
* - AI 披露横幅
|
||||
* - 日期分隔条
|
||||
* - 消息气泡(AI 左 / 用户右)
|
||||
* - AI 正在回复时的加载动画气泡
|
||||
*
|
||||
* 滚动行为:
|
||||
* - 新消息到达时自动滚到底部
|
||||
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ChatArea({ messages, isReplyingAI, isGuest }: 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">
|
||||
<AiDisclosure />
|
||||
|
||||
{messages.length === 0 && !isReplyingAI && (
|
||||
<EmptyState isGuest={isGuest} />
|
||||
)}
|
||||
|
||||
{renderMessagesWithDateHeaders(messages)}
|
||||
|
||||
{isReplyingAI && <LottieMessageBubble />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||
function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
|
||||
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}
|
||||
content={item.message.content}
|
||||
imageUrl={item.message.imageUrl}
|
||||
isFromAI={item.message.isFromAI}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function AiDisclosure() {
|
||||
return (
|
||||
<div className={styles.aiDisclosure}>
|
||||
You're chatting with an AI companion.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ isGuest }: { isGuest: boolean }) {
|
||||
return (
|
||||
<div className={styles.aiDisclosure}>
|
||||
{isGuest
|
||||
? "Say hi to start chatting. (Guest mode · 40 free messages)"
|
||||
: "Say hi to start chatting."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user