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
@@ -0,0 +1,110 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePullToRefresh } from "../use-pull-to-refresh";
function Harness({
disabled = false,
refreshing,
onRefresh,
}: {
disabled?: boolean;
refreshing?: boolean;
onRefresh: () => Promise<void> | void;
}) {
const pull = usePullToRefresh(() => onRefresh(), {
disabled,
refreshing,
});
return (
<div
aria-label="Pull container"
data-distance={pull.pullDistance}
data-pulling={pull.isPulling}
{...pull.touchHandlers}
/>
);
}
describe("usePullToRefresh", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("triggers once after pulling beyond the threshold", () => {
const onRefresh = vi.fn();
act(() => root.render(<Harness onRefresh={onRefresh} />));
const target = getTarget(container);
pull(target, 180);
expect(onRefresh).toHaveBeenCalledOnce();
});
it("does not trigger below the threshold or while disabled", () => {
const onRefresh = vi.fn();
act(() => root.render(<Harness onRefresh={onRefresh} />));
pull(getTarget(container), 80);
expect(onRefresh).not.toHaveBeenCalled();
act(() =>
root.render(<Harness disabled onRefresh={onRefresh} />),
);
pull(getTarget(container), 180);
expect(onRefresh).not.toHaveBeenCalled();
});
it("blocks another gesture while refresh is externally controlled", () => {
const onRefresh = vi.fn();
act(() =>
root.render(<Harness refreshing onRefresh={onRefresh} />),
);
pull(getTarget(container), 180);
expect(onRefresh).not.toHaveBeenCalled();
});
});
function getTarget(container: HTMLElement): HTMLDivElement {
const target = container.querySelector<HTMLDivElement>(
'[aria-label="Pull container"]',
);
if (!target) throw new Error("Pull container was not rendered");
return target;
}
function pull(target: HTMLDivElement, 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;
}
+129 -59
View File
@@ -1,69 +1,139 @@
"use client";
/**
* 下拉刷新 hook(占位实现)
*
*
*
* 当前实现:最小可用版本 —— 监听顶部 overscroll 触发 onRefresh。
* 后续可替换为 react-use / react-easy-refresh。
*/
import { useEffect, useRef, useState } from "react";
import {
useEffect,
useRef,
useState,
type TouchEvent as ReactTouchEvent,
} from "react";
export interface PullToRefreshOptions {
threshold?: number;
maxPullDistance?: number;
disabled?: boolean;
refreshing?: boolean;
}
export interface PullToRefreshTouchHandlers {
onTouchStart: (event: ReactTouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: ReactTouchEvent<HTMLDivElement>) => void;
onTouchEnd: (event: ReactTouchEvent<HTMLDivElement>) => void;
onTouchCancel: () => void;
}
export interface PullToRefreshState {
isRefreshing: boolean;
isPulling: boolean;
isReleaseReady: boolean;
pullDistance: number;
touchHandlers: PullToRefreshTouchHandlers;
}
export function usePullToRefresh(
onRefresh: () => Promise<void> | void,
options: { threshold?: number; disabled?: boolean } = {},
): {
isRefreshing: boolean;
pullDistance: number;
containerRef: React.RefObject<HTMLDivElement | null>;
} {
const { threshold = 80, disabled = false } = options;
const [isRefreshing, setIsRefreshing] = useState(false);
onRefresh: (container: HTMLDivElement) => Promise<void> | void,
options: PullToRefreshOptions = {},
): PullToRefreshState {
const {
threshold = 72,
maxPullDistance = threshold * 1.5,
disabled = false,
refreshing,
} = options;
const [internalRefreshing, setInternalRefreshing] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const startY = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const startYRef = useRef<number | null>(null);
const touchIdRef = useRef<number | null>(null);
const pullDistanceRef = useRef(0);
const refreshPendingRef = useRef(false);
const mountedRef = useRef(true);
const isRefreshing = refreshing ?? internalRefreshing;
useEffect(() => {
if (disabled || typeof window === "undefined") return;
const el = containerRef.current;
if (!el) return;
const onTouchStart = (e: TouchEvent) => {
if (el.scrollTop > 0) return;
startY.current = e.touches[0]?.clientY ?? null;
};
const onTouchMove = (e: TouchEvent) => {
if (startY.current == null) return;
const y = e.touches[0]?.clientY ?? startY.current;
const delta = y - startY.current;
if (delta > 0 && el.scrollTop <= 0) {
setPullDistance(Math.min(delta, threshold * 1.5));
}
};
const onTouchEnd = async () => {
if (startY.current == null) return;
if (pullDistance >= threshold && !isRefreshing) {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
setPullDistance(0);
}
} else {
setPullDistance(0);
}
startY.current = null;
};
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: true });
el.addEventListener("touchend", onTouchEnd);
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
mountedRef.current = false;
};
}, [threshold, disabled, isRefreshing, onRefresh, pullDistance]);
}, []);
return { isRefreshing, pullDistance, containerRef };
const resetGesture = () => {
startYRef.current = null;
touchIdRef.current = null;
pullDistanceRef.current = 0;
setIsPulling(false);
setPullDistance(0);
};
const runRefresh = async (container: HTMLDivElement) => {
if (refreshPendingRef.current || isRefreshing) return;
refreshPendingRef.current = true;
if (refreshing === undefined) setInternalRefreshing(true);
try {
await onRefresh(container);
} finally {
refreshPendingRef.current = false;
if (refreshing === undefined && mountedRef.current) {
setInternalRefreshing(false);
}
}
};
const onTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
const element = event.currentTarget;
if (disabled || isRefreshing || element.scrollTop > 0) return;
const touch = event.touches[0];
if (!touch || event.touches.length !== 1) return;
startYRef.current = touch.clientY;
touchIdRef.current = touch.identifier;
setIsPulling(true);
};
const onTouchMove = (event: ReactTouchEvent<HTMLDivElement>) => {
if (
disabled ||
isRefreshing ||
startYRef.current === null ||
touchIdRef.current === null
) {
return;
}
const touch = Array.from(event.touches).find(
(item) => item.identifier === touchIdRef.current,
);
if (!touch) return;
const delta = touch.clientY - startYRef.current;
if (delta <= 0 || event.currentTarget.scrollTop > 0) {
pullDistanceRef.current = 0;
setPullDistance(0);
return;
}
event.preventDefault();
const nextDistance = Math.min(maxPullDistance, delta * 0.55);
pullDistanceRef.current = nextDistance;
setPullDistance(nextDistance);
};
const onTouchEnd = (event: ReactTouchEvent<HTMLDivElement>) => {
if (startYRef.current === null) return;
const shouldRefresh =
!disabled &&
!isRefreshing &&
pullDistanceRef.current >= threshold;
resetGesture();
if (shouldRefresh) void runRefresh(event.currentTarget);
};
const effectivePullDistance = disabled || isRefreshing ? 0 : pullDistance;
return {
isRefreshing,
isPulling: !disabled && !isRefreshing && isPulling,
isReleaseReady: effectivePullDistance >= threshold,
pullDistance: effectivePullDistance,
touchHandlers: {
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: resetGesture,
},
};
}