refactor(storage): use unstorage for session state

This commit is contained in:
2026-07-01 18:36:26 +08:00
parent 0251916a8a
commit ae6578923b
13 changed files with 398 additions and 246 deletions
+17 -31
View File
@@ -1,18 +1,15 @@
"use client";
const CHAT_SCROLL_STORAGE_KEY = "cozsweet:chat:scroll";
import {
ChatScrollSessionStorage,
type ChatScrollSession,
} from "@/lib/chat/chat_scroll_session_storage";
import { Result } from "@/utils";
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 {
@@ -44,33 +41,22 @@ export function saveChatScrollSnapshot(
};
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.
}
void ChatScrollSessionStorage.setSnapshot(payload);
}
export function consumeChatScrollSnapshot(): ChatScrollSession | null {
export async function consumeChatScrollSnapshot(): Promise<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;
const memoryPayload = readValidChatScrollSession(memoryScrollSession);
memoryScrollSession = null;
if (memoryPayload !== null) {
void ChatScrollSessionStorage.clearSnapshot();
return memoryPayload;
}
const storageResult = await ChatScrollSessionStorage.consumeSnapshot();
if (Result.isErr(storageResult)) return null;
return readValidChatScrollSession(storageResult.data);
}
export function getChatScrollRestoreTop(
+37 -23
View File
@@ -81,35 +81,49 @@ export function ChatArea({
if (restoredScrollRef.current || messages.length === 0) return;
const scrollNode = scrollRef.current;
const snapshot = consumeChatScrollSnapshot();
if (!scrollNode || snapshot === null) return;
if (!scrollNode) return;
restoredScrollRef.current = true;
let cancelled = false;
let frame: number | null = null;
let timer: number | null = null;
let observerTimer: number | null = null;
let lateRestoreTimer: number | null = null;
let resizeObserver: ResizeObserver | null = null;
const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
const restore = async () => {
const snapshot = await consumeChatScrollSnapshot();
if (cancelled || snapshot === null) return;
restoredScrollRef.current = true;
const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
};
restoreScroll();
frame = window.requestAnimationFrame(restoreScroll);
timer = window.setTimeout(restoreScroll, 120);
resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(restoreScroll);
Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child);
});
observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect();
}, 800);
lateRestoreTimer = window.setTimeout(restoreScroll, 600);
};
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);
void restore();
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(timer);
window.clearTimeout(observerTimer);
window.clearTimeout(lateRestoreTimer);
cancelled = true;
if (frame !== null) window.cancelAnimationFrame(frame);
if (timer !== null) window.clearTimeout(timer);
if (observerTimer !== null) window.clearTimeout(observerTimer);
if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer);
resizeObserver?.disconnect();
};
}, [messages.length]);
@@ -56,27 +56,37 @@ export function useChatUnlockNavigationFlow({
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (
!pending ||
pending.returnUrl !== returnUrl ||
!matchesPendingUnlockScope({
pending,
expectedKind,
expectedMessageId,
})
) {
return;
}
let cancelled = false;
const consumed = consumePendingChatUnlock();
if (!consumed) return;
const resumePendingUnlock = async () => {
const pending = await peekPendingChatUnlock();
if (
cancelled ||
!pending ||
pending.returnUrl !== returnUrl ||
!matchesPendingUnlockScope({
pending,
expectedKind,
expectedMessageId,
})
) {
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
const consumed = await consumePendingChatUnlock();
if (cancelled || !consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
};
void resumePendingUnlock();
return () => {
cancelled = true;
};
}, [
chatDispatch,
chatState.historyLoaded,
@@ -91,13 +101,15 @@ export function useChatUnlockNavigationFlow({
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
void (async () => {
await savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
return;
}
@@ -115,19 +127,21 @@ export function useChatUnlockNavigationFlow({
function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
void (async () => {
await savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
})();
}
return {