feat(chat): implement pull-to-refresh functionality and pagination for chat history

This commit is contained in:
2026-07-15 18:19:35 +08:00
parent 05ca15be48
commit c37a2f9040
21 changed files with 892 additions and 66 deletions
@@ -144,12 +144,47 @@ describe("ChatArea scrolling", () => {
expect(scrollNode.scrollTop).toBe(100);
});
it("preserves the visible message after older history is prepended", () => {
scrollHeight = 1_000;
clientHeight = 300;
const onLoadMoreHistory = vi.fn();
const history = [createMessage("history-1")];
renderChatArea(root, history, true, {
canLoadMoreHistory: true,
onLoadMoreHistory,
});
const scrollNode = getScrollNode(container);
scrollNode.scrollTop = 0;
act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));
pull(scrollNode, 180);
expect(onLoadMoreHistory).toHaveBeenCalledOnce();
scrollHeight = 1_400;
renderChatArea(
root,
[createMessage("older-1"), ...history],
true,
{
canLoadMoreHistory: true,
onLoadMoreHistory,
},
);
expect(scrollNode.scrollTop).toBe(400);
});
});
function renderChatArea(
root: Root,
messages: readonly UiMessage[],
initialScrollReady: boolean,
historyOptions: {
canLoadMoreHistory?: boolean;
isLoadingMoreHistory?: boolean;
onLoadMoreHistory?: () => void;
} = {},
): void {
act(() => {
root.render(
@@ -157,11 +192,32 @@ function renderChatArea(
messages={messages}
isReplyingAI={false}
initialScrollReady={initialScrollReady}
{...historyOptions}
/>,
);
});
}
function pull(target: HTMLElement, distance: number): void {
act(() => {
target.dispatchEvent(createTouchEvent("touchstart", 0));
target.dispatchEvent(createTouchEvent("touchmove", distance));
target.dispatchEvent(createTouchEvent("touchend", distance, true));
});
}
function createTouchEvent(
type: string,
clientY: number,
ended = false,
): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, "touches", {
value: ended ? [] : [{ identifier: 1, clientY }],
});
return event;
}
function createMessage(id: string): UiMessage {
return {
id,
@@ -45,7 +45,7 @@ export function BrowserHintOverlay({
onClick={handleClick}
>
<span
className="inline-flex size-7.5 items-center justify-center rounded-full bg-(--color-accent,#f84d96) text-white shadow-[0_4px_12px_rgba(248,77,150,0.34)]"
className="inline-flex size-7.5 items-center justify-center rounded-full bg-accent text-white shadow-[0_4px_12px_rgba(248,77,150,0.34)]"
aria-hidden="true"
>
<ExternalLink size={15} strokeWidth={2.4} />
@@ -54,7 +54,7 @@ export function BrowserHintOverlay({
<span className="wrap-break-word text-(length:--font-size-sm,12px) font-extrabold leading-tight text-white">
{title}
</span>
<span className="wrap-break-word text-[11px] font-semibold leading-[1.25] text-[#f3dfe8]">
<span className="wrap-break-word text-[11px] font-semibold leading-tight text-[#f3dfe8]">
{description}
</span>
</span>
@@ -1,9 +1,13 @@
/* ChatArea 消息列表样式 */
.area {
--chat-pull-offset: 0px;
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior-y: contain;
padding:
var(--spacing-lg, 16px)
calc(var(--chat-inline-padding, 20px) + var(--app-safe-right, 0px))
@@ -11,11 +15,49 @@
calc(var(--chat-inline-padding, 20px) + var(--app-safe-left, 0px));
}
.pullIndicator {
position: absolute;
top: var(--spacing-sm, 8px);
left: 50%;
z-index: 2;
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(24, 20, 30, 0.86);
color: rgba(255, 255, 255, 0.9);
font-size: var(--responsive-caption, var(--font-size-sm, 12px));
font-weight: 700;
opacity: 1;
pointer-events: none;
transform: translate(-50%, calc(var(--chat-pull-offset) - 42px));
white-space: nowrap;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
}
.pullIndicator[aria-hidden="true"] {
opacity: 0;
}
.pullIndicator svg[data-spinning="true"] {
animation: pullIndicatorSpin 0.8s linear infinite;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-3, 12px);
min-width: 0;
transform: translateY(var(--chat-pull-offset));
transition: transform 180ms ease;
will-change: transform;
}
.area[data-pulling="true"] .content {
transition: none;
}
.dateHeader {
@@ -143,3 +185,9 @@
0%, 60%, 100% { opacity: 0.2; }
30% { opacity: 1; }
}
@keyframes pullIndicatorSpin {
to {
transform: rotate(360deg);
}
}
+74 -1
View File
@@ -15,13 +15,16 @@
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,
@@ -34,6 +37,7 @@ 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,
@@ -44,30 +48,69 @@ export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
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,
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 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) {
@@ -78,6 +121,16 @@ export function ChatArea({
const scrollNode = scrollRef.current;
if (!scrollNode) return;
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;
@@ -96,7 +149,7 @@ export function ChatArea({
});
return () => cancelAnimationFrame(frameId);
}, [initialScrollReady, isReplyingAI, messages]);
}, [initialScrollReady, isLoadingMoreHistory, isReplyingAI, messages]);
useEffect(() => {
const scrollNode = scrollRef.current;
@@ -124,10 +177,30 @@ export function ChatArea({
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 />