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 type { UiMessage } from "@/data/dto/chat";
|
||||||
|
|
||||||
|
import {
|
||||||
|
consumeChatScrollSnapshot,
|
||||||
|
getChatScrollRestoreTop,
|
||||||
|
listenChatScrollSnapshotSave,
|
||||||
|
saveChatScrollSnapshot,
|
||||||
|
} from "../chat-scroll-session";
|
||||||
import { AiDisclosureBanner } from "./ai-disclosure-banner";
|
import { AiDisclosureBanner } from "./ai-disclosure-banner";
|
||||||
import { DateHeader } from "./date-header";
|
import { DateHeader } from "./date-header";
|
||||||
import { LottieMessageBubble } from "./lottie-message-bubble";
|
import { LottieMessageBubble } from "./lottie-message-bubble";
|
||||||
@@ -42,6 +48,23 @@ export function ChatArea({
|
|||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -54,8 +77,67 @@ export function ChatArea({
|
|||||||
}
|
}
|
||||||
}, [messages.length]);
|
}, [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 (
|
return (
|
||||||
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
<main
|
||||||
|
ref={scrollRef}
|
||||||
|
className={styles.area}
|
||||||
|
aria-label="Chat messages"
|
||||||
|
onScroll={saveCurrentScrollSnapshot}
|
||||||
|
>
|
||||||
<AiDisclosureBanner />
|
<AiDisclosureBanner />
|
||||||
|
|
||||||
{renderMessagesWithDateHeaders(
|
{renderMessagesWithDateHeaders(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@/lib/chat/chat_media_url";
|
} from "@/lib/chat/chat_media_url";
|
||||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_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";
|
import styles from "./image-bubble.module.css";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
@@ -46,7 +47,8 @@ export function ImageBubble({
|
|||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
router.push(ROUTE_BUILDERS.chatImage(messageId));
|
requestChatScrollSnapshotSave();
|
||||||
|
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ChatImageViewerScreen({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
router.push(ROUTES.chat);
|
router.push(ROUTES.chat, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlockImagePaywall = () => {
|
const handleUnlockImagePaywall = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user