fix(chat): restore scroll after image viewer return
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
const CHAT_SCROLL_STORAGE_KEY = "cozsweet:chat:scroll";
|
||||
const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll";
|
||||
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
|
||||
|
||||
interface ChatScrollSession {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
distanceFromBottom: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
let memoryScrollSession: ChatScrollSession | null = null;
|
||||
|
||||
export function requestChatScrollSnapshotSave(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(CHAT_SCROLL_SAVE_EVENT));
|
||||
}
|
||||
|
||||
export function listenChatScrollSnapshotSave(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
|
||||
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
|
||||
}
|
||||
|
||||
export function saveChatScrollSnapshot(
|
||||
scrollNode: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollNode;
|
||||
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
|
||||
|
||||
const payload: ChatScrollSession = {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceFromBottom: Math.max(0, scrollHeight - scrollTop - clientHeight),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
memoryScrollSession = payload;
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
CHAT_SCROLL_STORAGE_KEY,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
} catch {
|
||||
// Scroll restoration is best-effort; storage failures should not affect chat.
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeChatScrollSnapshot(): ChatScrollSession | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const payload =
|
||||
readValidChatScrollSession(memoryScrollSession) ??
|
||||
readValidChatScrollSession(
|
||||
JSON.parse(window.sessionStorage.getItem(CHAT_SCROLL_STORAGE_KEY) ?? "null"),
|
||||
);
|
||||
|
||||
memoryScrollSession = null;
|
||||
window.sessionStorage.removeItem(CHAT_SCROLL_STORAGE_KEY);
|
||||
return payload;
|
||||
} catch {
|
||||
memoryScrollSession = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getChatScrollRestoreTop(
|
||||
scrollNode: Pick<HTMLElement, "scrollHeight" | "clientHeight">,
|
||||
snapshot: ChatScrollSession,
|
||||
): number {
|
||||
const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight);
|
||||
if (snapshot.distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD) {
|
||||
return maxScrollTop;
|
||||
}
|
||||
return Math.min(Math.max(0, snapshot.scrollTop), maxScrollTop);
|
||||
}
|
||||
|
||||
function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const payload = value as Partial<ChatScrollSession>;
|
||||
if (
|
||||
typeof payload.scrollTop !== "number" ||
|
||||
!Number.isFinite(payload.scrollTop) ||
|
||||
typeof payload.scrollHeight !== "number" ||
|
||||
!Number.isFinite(payload.scrollHeight) ||
|
||||
typeof payload.clientHeight !== "number" ||
|
||||
!Number.isFinite(payload.clientHeight) ||
|
||||
typeof payload.distanceFromBottom !== "number" ||
|
||||
!Number.isFinite(payload.distanceFromBottom) ||
|
||||
typeof payload.savedAt !== "number" ||
|
||||
!Number.isFinite(payload.savedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - payload.savedAt > CHAT_SCROLL_MAX_AGE_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scrollTop: Math.max(0, payload.scrollTop),
|
||||
scrollHeight: Math.max(0, payload.scrollHeight),
|
||||
clientHeight: Math.max(0, payload.clientHeight),
|
||||
distanceFromBottom: Math.max(0, payload.distanceFromBottom),
|
||||
savedAt: payload.savedAt,
|
||||
};
|
||||
}
|
||||
@@ -12,10 +12,16 @@
|
||||
* - 新消息到达时自动滚到底部
|
||||
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
import {
|
||||
consumeChatScrollSnapshot,
|
||||
getChatScrollRestoreTop,
|
||||
listenChatScrollSnapshotSave,
|
||||
saveChatScrollSnapshot,
|
||||
} from "../chat-scroll-session";
|
||||
import { AiDisclosureBanner } from "./ai-disclosure-banner";
|
||||
import { DateHeader } from "./date-header";
|
||||
import { LottieMessageBubble } from "./lottie-message-bubble";
|
||||
@@ -42,6 +48,23 @@ export function ChatArea({
|
||||
}: ChatAreaProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevLengthRef = useRef(messages.length);
|
||||
const restoredScrollRef = useRef(false);
|
||||
const saveFrameRef = useRef<number | null>(null);
|
||||
|
||||
const saveCurrentScrollSnapshotNow = () => {
|
||||
const scrollNode = scrollRef.current;
|
||||
if (!scrollNode) return;
|
||||
saveChatScrollSnapshot(scrollNode);
|
||||
};
|
||||
|
||||
const saveCurrentScrollSnapshot = () => {
|
||||
if (saveFrameRef.current !== null) return;
|
||||
|
||||
saveFrameRef.current = window.requestAnimationFrame(() => {
|
||||
saveFrameRef.current = null;
|
||||
saveCurrentScrollSnapshotNow();
|
||||
});
|
||||
};
|
||||
|
||||
// 新消息 → 滚到底部
|
||||
useEffect(() => {
|
||||
@@ -54,8 +77,67 @@ export function ChatArea({
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoredScrollRef.current || messages.length === 0) return;
|
||||
|
||||
const scrollNode = scrollRef.current;
|
||||
const snapshot = consumeChatScrollSnapshot();
|
||||
if (!scrollNode || snapshot === null) return;
|
||||
|
||||
restoredScrollRef.current = true;
|
||||
|
||||
const restoreScroll = () => {
|
||||
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
|
||||
};
|
||||
|
||||
restoreScroll();
|
||||
const frame = window.requestAnimationFrame(restoreScroll);
|
||||
const timer = window.setTimeout(restoreScroll, 120);
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(restoreScroll);
|
||||
Array.from(scrollNode.children).forEach((child) => {
|
||||
resizeObserver?.observe(child);
|
||||
});
|
||||
const observerTimer = window.setTimeout(() => {
|
||||
resizeObserver?.disconnect();
|
||||
}, 800);
|
||||
const lateRestoreTimer = window.setTimeout(restoreScroll, 600);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.clearTimeout(timer);
|
||||
window.clearTimeout(observerTimer);
|
||||
window.clearTimeout(lateRestoreTimer);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollNode = scrollRef.current;
|
||||
const stopListening = listenChatScrollSnapshotSave(
|
||||
saveCurrentScrollSnapshotNow,
|
||||
);
|
||||
|
||||
return () => {
|
||||
stopListening();
|
||||
if (saveFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(saveFrameRef.current);
|
||||
saveFrameRef.current = null;
|
||||
}
|
||||
if (!scrollNode) return;
|
||||
saveChatScrollSnapshot(scrollNode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
||||
<main
|
||||
ref={scrollRef}
|
||||
className={styles.area}
|
||||
aria-label="Chat messages"
|
||||
onScroll={saveCurrentScrollSnapshot}
|
||||
>
|
||||
<AiDisclosureBanner />
|
||||
|
||||
{renderMessagesWithDateHeaders(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/lib/chat/chat_media_url";
|
||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
|
||||
import { requestChatScrollSnapshotSave } from "../chat-scroll-session";
|
||||
import styles from "./image-bubble.module.css";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
@@ -46,7 +47,8 @@ export function ImageBubble({
|
||||
|
||||
const openImage = () => {
|
||||
if (!messageId) return;
|
||||
router.push(ROUTE_BUILDERS.chatImage(messageId));
|
||||
requestChatScrollSnapshotSave();
|
||||
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ChatImageViewerScreen({
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
router.push(ROUTES.chat);
|
||||
router.push(ROUTES.chat, { scroll: false });
|
||||
};
|
||||
|
||||
const handleUnlockImagePaywall = () => {
|
||||
|
||||
Reference in New Issue
Block a user