refactor(storage): use unstorage for session state
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -122,18 +122,22 @@ export function useSubscriptionPaymentFlow({
|
||||
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.replace(consumeSubscriptionExitUrl(returnTo));
|
||||
void (async () => {
|
||||
router.replace(await consumeSubscriptionExitUrl(returnTo));
|
||||
})();
|
||||
};
|
||||
|
||||
const handlePaymentSuccessClose = () => {
|
||||
setShowPaymentSuccessDialog(false);
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
const pendingExitUrl = peekSubscriptionExplicitExitUrl();
|
||||
if (pendingExitUrl) {
|
||||
router.replace(pendingExitUrl);
|
||||
return;
|
||||
}
|
||||
router.replace(consumeSubscriptionExitUrl(returnTo));
|
||||
void (async () => {
|
||||
setShowPaymentSuccessDialog(false);
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
||||
if (pendingExitUrl) {
|
||||
router.replace(pendingExitUrl);
|
||||
return;
|
||||
}
|
||||
router.replace(await consumeSubscriptionExitUrl(returnTo));
|
||||
})();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user