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
+16 -30
View File
@@ -1,18 +1,15 @@
"use client"; "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_SAVE_EVENT = "cozsweet:chat:save-scroll";
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000; const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80; const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
interface ChatScrollSession {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
distanceFromBottom: number;
savedAt: number;
}
let memoryScrollSession: ChatScrollSession | null = null; let memoryScrollSession: ChatScrollSession | null = null;
export function requestChatScrollSnapshotSave(): void { export function requestChatScrollSnapshotSave(): void {
@@ -44,33 +41,22 @@ export function saveChatScrollSnapshot(
}; };
memoryScrollSession = payload; memoryScrollSession = payload;
try { void ChatScrollSessionStorage.setSnapshot(payload);
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 { export async function consumeChatScrollSnapshot(): Promise<ChatScrollSession | null> {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { const memoryPayload = readValidChatScrollSession(memoryScrollSession);
const payload =
readValidChatScrollSession(memoryScrollSession) ??
readValidChatScrollSession(
JSON.parse(window.sessionStorage.getItem(CHAT_SCROLL_STORAGE_KEY) ?? "null"),
);
memoryScrollSession = null; memoryScrollSession = null;
window.sessionStorage.removeItem(CHAT_SCROLL_STORAGE_KEY); if (memoryPayload !== null) {
return payload; void ChatScrollSessionStorage.clearSnapshot();
} catch { return memoryPayload;
memoryScrollSession = null;
return null;
} }
const storageResult = await ChatScrollSessionStorage.consumeSnapshot();
if (Result.isErr(storageResult)) return null;
return readValidChatScrollSession(storageResult.data);
} }
export function getChatScrollRestoreTop( export function getChatScrollRestoreTop(
+25 -11
View File
@@ -81,8 +81,18 @@ export function ChatArea({
if (restoredScrollRef.current || messages.length === 0) return; if (restoredScrollRef.current || messages.length === 0) return;
const scrollNode = scrollRef.current; const scrollNode = scrollRef.current;
const snapshot = consumeChatScrollSnapshot(); if (!scrollNode) return;
if (!scrollNode || snapshot === null) return;
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 restore = async () => {
const snapshot = await consumeChatScrollSnapshot();
if (cancelled || snapshot === null) return;
restoredScrollRef.current = true; restoredScrollRef.current = true;
@@ -91,25 +101,29 @@ export function ChatArea({
}; };
restoreScroll(); restoreScroll();
const frame = window.requestAnimationFrame(restoreScroll); frame = window.requestAnimationFrame(restoreScroll);
const timer = window.setTimeout(restoreScroll, 120); timer = window.setTimeout(restoreScroll, 120);
const resizeObserver = resizeObserver =
typeof ResizeObserver === "undefined" typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver(restoreScroll); : new ResizeObserver(restoreScroll);
Array.from(scrollNode.children).forEach((child) => { Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child); resizeObserver?.observe(child);
}); });
const observerTimer = window.setTimeout(() => { observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}, 800); }, 800);
const lateRestoreTimer = window.setTimeout(restoreScroll, 600); lateRestoreTimer = window.setTimeout(restoreScroll, 600);
};
void restore();
return () => { return () => {
window.cancelAnimationFrame(frame); cancelled = true;
window.clearTimeout(timer); if (frame !== null) window.cancelAnimationFrame(frame);
window.clearTimeout(observerTimer); if (timer !== null) window.clearTimeout(timer);
window.clearTimeout(lateRestoreTimer); if (observerTimer !== null) window.clearTimeout(observerTimer);
if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}; };
}, [messages.length]); }, [messages.length]);
@@ -56,8 +56,12 @@ export function useChatUnlockNavigationFlow({
useEffect(() => { useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return; if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock(); let cancelled = false;
const resumePendingUnlock = async () => {
const pending = await peekPendingChatUnlock();
if ( if (
cancelled ||
!pending || !pending ||
pending.returnUrl !== returnUrl || pending.returnUrl !== returnUrl ||
!matchesPendingUnlockScope({ !matchesPendingUnlockScope({
@@ -69,14 +73,20 @@ export function useChatUnlockNavigationFlow({
return; return;
} }
const consumed = consumePendingChatUnlock(); const consumed = await consumePendingChatUnlock();
if (!consumed) return; if (cancelled || !consumed) return;
chatDispatch({ chatDispatch({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
messageId: consumed.messageId, messageId: consumed.messageId,
kind: consumed.kind, kind: consumed.kind,
}); });
};
void resumePendingUnlock();
return () => {
cancelled = true;
};
}, [ }, [
chatDispatch, chatDispatch,
chatState.historyLoaded, chatState.historyLoaded,
@@ -91,13 +101,15 @@ export function useChatUnlockNavigationFlow({
kind: PendingChatUnlockKind, kind: PendingChatUnlockKind,
): void { ): void {
if (!isAuthenticatedUser) { if (!isAuthenticatedUser) {
savePendingChatUnlock({ void (async () => {
await savePendingChatUnlock({
messageId, messageId,
kind, kind,
returnUrl, returnUrl,
stage: "auth", stage: "auth",
}); });
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl)); router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
return; return;
} }
@@ -115,7 +127,8 @@ export function useChatUnlockNavigationFlow({
function confirmInsufficientCreditsDialog(): void { function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return; if (!unlockPaywallRequest) return;
savePendingChatUnlock({ void (async () => {
await savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId, messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind, kind: unlockPaywallRequest.kind,
returnUrl, returnUrl,
@@ -128,6 +141,7 @@ export function useChatUnlockNavigationFlow({
{ returnTo: "chat" }, { returnTo: "chat" },
), ),
); );
})();
} }
return { return {
@@ -122,18 +122,22 @@ export function useSubscriptionPaymentFlow({
}, [payment.currentOrderId, payment.isPaid, payment.status]); }, [payment.currentOrderId, payment.isPaid, payment.status]);
const handleBackClick = () => { const handleBackClick = () => {
router.replace(consumeSubscriptionExitUrl(returnTo)); void (async () => {
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
}; };
const handlePaymentSuccessClose = () => { const handlePaymentSuccessClose = () => {
void (async () => {
setShowPaymentSuccessDialog(false); setShowPaymentSuccessDialog(false);
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
const pendingExitUrl = peekSubscriptionExplicitExitUrl(); const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
if (pendingExitUrl) { if (pendingExitUrl) {
router.replace(pendingExitUrl); router.replace(pendingExitUrl);
return; return;
} }
router.replace(consumeSubscriptionExitUrl(returnTo)); router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
}; };
return { return {
+3
View File
@@ -23,6 +23,9 @@ export const StorageKeys = {
// chat // chat
chatHistory: "chat_history", chatHistory: "chat_history",
chatScrollSession: "chat_scroll_session",
pendingChatImageReturn: "pending_chat_image_return",
pendingChatUnlock: "pending_chat_unlock",
// pwa / app info // pwa / app info
lastPwaDialogShown: "last_pwa_dialog_shown", lastPwaDialogShown: "last_pwa_dialog_shown",
@@ -0,0 +1,47 @@
"use client";
import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil, type Result as ResultT } from "@/utils";
const ChatScrollSessionSchema = z.object({
scrollTop: z.number(),
scrollHeight: z.number(),
clientHeight: z.number(),
distanceFromBottom: z.number(),
savedAt: z.number(),
});
export type ChatScrollSession = z.output<typeof ChatScrollSessionSchema>;
export class ChatScrollSessionStorage {
private constructor() {}
static setSnapshot(snapshot: ChatScrollSession): Promise<ResultT<void>> {
return SessionAsyncUtil.setJson(
StorageKeys.chatScrollSession,
snapshot,
ChatScrollSessionSchema,
);
}
static getSnapshot(): Promise<ResultT<ChatScrollSession | null>> {
return SessionAsyncUtil.getJson(
StorageKeys.chatScrollSession,
ChatScrollSessionSchema,
);
}
static clearSnapshot(): Promise<ResultT<void>> {
return SessionAsyncUtil.remove(StorageKeys.chatScrollSession);
}
static async consumeSnapshot(): Promise<ResultT<ChatScrollSession | null>> {
const result = await ChatScrollSessionStorage.getSnapshot();
const cleared = await ChatScrollSessionStorage.clearSnapshot();
if (Result.isErr(result)) return result;
if (Result.isErr(cleared)) return cleared;
return result;
}
}
@@ -35,34 +35,38 @@ describe("subscription exit helpers", () => {
peekPendingChatUnlockMock.mockReset(); peekPendingChatUnlockMock.mockReset();
}); });
it("uses explicit chat image return urls first", () => { it("uses explicit chat image return urls first", async () => {
consumePendingChatImageReturnMock.mockReturnValue({ consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall", reason: "image_paywall",
messageId: "msg_1", messageId: "msg_1",
returnUrl: "/chat/image/msg_1", returnUrl: "/chat/image/msg_1",
createdAt: 1, createdAt: 1,
}); });
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
}); });
it("falls back to chat when requested", () => { it("falls back to chat when requested", async () => {
consumePendingChatImageReturnMock.mockReturnValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockReturnValue(null); peekPendingChatUnlockMock.mockResolvedValue(null);
expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat); await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(ROUTES.chat);
}); });
it("falls back to sidebar by default", () => { it("falls back to sidebar by default", async () => {
consumePendingChatImageReturnMock.mockReturnValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockReturnValue(null); peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar); expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar); await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
ROUTES.sidebar,
);
}); });
it("peeks chat unlock return urls without clearing them", () => { it("peeks chat unlock return urls without clearing them", async () => {
peekPendingChatUnlockMock.mockReturnValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -71,13 +75,15 @@ describe("subscription exit helpers", () => {
createdAt: 1, createdAt: 1,
}); });
expect(peekSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled(); expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
}); });
it("clears chat unlock return urls when consumed", () => { it("clears chat unlock return urls when consumed", async () => {
consumePendingChatImageReturnMock.mockReturnValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockReturnValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -86,7 +92,9 @@ describe("subscription exit helpers", () => {
createdAt: 1, createdAt: 1,
}); });
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
"/chat/image/msg_1",
);
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1); expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
}); });
}); });
+36 -37
View File
@@ -1,55 +1,54 @@
"use client"; "use client";
const STORAGE_KEY = "cozsweet.chat.pendingImageReturn"; import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000; const MAX_AGE_MS = 30 * 60 * 1000;
export interface PendingChatImageReturn { const PendingChatImageReturnSchema = z.object({
reason: "image_paywall"; reason: z.literal("image_paywall"),
messageId: string; messageId: z.string().min(1),
returnUrl: string; returnUrl: z.string().min(1),
createdAt: number; createdAt: z.number(),
} });
export function savePendingChatImageReturn(input: { export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema
>;
export async function savePendingChatImageReturn(input: {
messageId: string; messageId: string;
returnUrl: string; returnUrl: string;
}): void { }): Promise<void> {
if (typeof window === "undefined") return;
const payload: PendingChatImageReturn = { const payload: PendingChatImageReturn = {
reason: "image_paywall", reason: "image_paywall",
messageId: input.messageId, messageId: input.messageId,
returnUrl: input.returnUrl, returnUrl: input.returnUrl,
createdAt: Date.now(), createdAt: Date.now(),
}; };
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); await SessionAsyncUtil.setJson(
StorageKeys.pendingChatImageReturn,
payload,
PendingChatImageReturnSchema,
);
} }
export function consumePendingChatImageReturn(): PendingChatImageReturn | null { export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
if (typeof window === "undefined") return null; const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatImageReturn,
PendingChatImageReturnSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
if (Result.isErr(result)) return null;
return parsePendingChatImageReturn(result.data);
}
const raw = window.sessionStorage.getItem(STORAGE_KEY); function parsePendingChatImageReturn(
window.sessionStorage.removeItem(STORAGE_KEY); value: PendingChatImageReturn | null,
if (!raw) return null; ): PendingChatImageReturn | null {
if (!value) return null;
try {
const value = JSON.parse(raw) as Partial<PendingChatImageReturn>;
if (value.reason !== "image_paywall") return null;
if (typeof value.messageId !== "string" || value.messageId.length === 0) {
return null;
}
if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) {
return null;
}
if (typeof value.createdAt !== "number") return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null; if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
return {
reason: value.reason,
messageId: value.messageId,
returnUrl: value.returnUrl,
createdAt: value.createdAt,
};
} catch {
return null;
}
} }
+53 -74
View File
@@ -1,27 +1,38 @@
"use client"; "use client";
const STORAGE_KEY = "cozsweet.chat.pendingUnlock"; import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000; const MAX_AGE_MS = 30 * 60 * 1000;
export type PendingChatUnlockKind = "private" | "voice" | "image"; export type PendingChatUnlockKind = "private" | "voice" | "image";
export type PendingChatUnlockStage = "auth" | "payment"; export type PendingChatUnlockStage = "auth" | "payment";
export interface PendingChatUnlock { const PendingChatUnlockSchema = z.object({
reason: "single_message_unlock"; reason: z.literal("single_message_unlock"),
messageId: string; messageId: z.string().min(1),
kind: PendingChatUnlockKind; kind: z.enum(["private", "voice", "image"]),
returnUrl: string; returnUrl: z
stage: PendingChatUnlockStage; .string()
createdAt: number; .min(1)
} .refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
createdAt: z.number(),
});
export function savePendingChatUnlock(input: { export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
export async function savePendingChatUnlock(input: {
messageId: string; messageId: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
returnUrl: string; returnUrl: string;
stage: PendingChatUnlockStage; stage: PendingChatUnlockStage;
}): void { }): Promise<void> {
if (typeof window === "undefined") return;
const payload: PendingChatUnlock = { const payload: PendingChatUnlock = {
reason: "single_message_unlock", reason: "single_message_unlock",
messageId: input.messageId, messageId: input.messageId,
@@ -30,80 +41,48 @@ export function savePendingChatUnlock(input: {
stage: input.stage, stage: input.stage,
createdAt: Date.now(), createdAt: Date.now(),
}; };
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); await SessionAsyncUtil.setJson(
StorageKeys.pendingChatUnlock,
payload,
PendingChatUnlockSchema,
);
} }
export function consumePendingChatUnlock(): PendingChatUnlock | null { export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
if (typeof window === "undefined") return null; const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
const raw = window.sessionStorage.getItem(STORAGE_KEY); PendingChatUnlockSchema,
window.sessionStorage.removeItem(STORAGE_KEY); );
if (!raw) return null; await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
if (Result.isErr(result)) return null;
return parsePendingChatUnlock(raw); return parsePendingChatUnlock(result.data);
} }
export function peekPendingChatUnlock(): PendingChatUnlock | null { export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
if (typeof window === "undefined") return null; const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
const raw = window.sessionStorage.getItem(STORAGE_KEY); PendingChatUnlockSchema,
if (!raw) return null; );
if (Result.isErr(result)) return null;
const value = parsePendingChatUnlock(raw); const value = parsePendingChatUnlock(result.data);
if (!value) { if (!value) {
window.sessionStorage.removeItem(STORAGE_KEY); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
} }
return value; return value;
} }
export function hasPendingChatUnlock(): boolean { export async function hasPendingChatUnlock(): Promise<boolean> {
return peekPendingChatUnlock() !== null; return (await peekPendingChatUnlock()) !== null;
} }
export function clearPendingChatUnlock(): void { export async function clearPendingChatUnlock(): Promise<void> {
if (typeof window === "undefined") return; await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
window.sessionStorage.removeItem(STORAGE_KEY);
} }
function parsePendingChatUnlock(raw: string): PendingChatUnlock | null { function parsePendingChatUnlock(
try { value: PendingChatUnlock | null,
const value = JSON.parse(raw) as Partial<PendingChatUnlock>; ): PendingChatUnlock | null {
if (value.reason !== "single_message_unlock") return null; if (!value) return null;
if (typeof value.messageId !== "string" || value.messageId.length === 0) {
return null;
}
if (!isPendingChatUnlockKind(value.kind)) return null;
if (!isPendingChatUnlockStage(value.stage)) return null;
if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) {
return null;
}
if (!value.returnUrl.startsWith("/") || value.returnUrl.startsWith("//")) {
return null;
}
if (typeof value.createdAt !== "number") return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null; if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
return {
reason: value.reason,
messageId: value.messageId,
kind: value.kind,
returnUrl: value.returnUrl,
stage: value.stage,
createdAt: value.createdAt,
};
} catch {
return null;
}
}
function isPendingChatUnlockKind(
value: unknown,
): value is PendingChatUnlockKind {
return value === "private" || value === "voice" || value === "image";
}
function isPendingChatUnlockStage(
value: unknown,
): value is PendingChatUnlockStage {
return value === "auth" || value === "payment";
} }
+9 -9
View File
@@ -10,19 +10,19 @@ import {
export type SubscriptionReturnTo = "chat" | null; export type SubscriptionReturnTo = "chat" | null;
export function consumeSubscriptionExplicitExitUrl(): string | null { export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingImageReturn = consumePendingChatImageReturn(); const pendingImageReturn = await consumePendingChatImageReturn();
if (pendingImageReturn) return pendingImageReturn.returnUrl; if (pendingImageReturn) return pendingImageReturn.returnUrl;
const pendingChatUnlock = peekPendingChatUnlock(); const pendingChatUnlock = await peekPendingChatUnlock();
if (pendingChatUnlock) { if (pendingChatUnlock) {
clearPendingChatUnlock(); await clearPendingChatUnlock();
return pendingChatUnlock.returnUrl; return pendingChatUnlock.returnUrl;
} }
return null; return null;
} }
export function peekSubscriptionExplicitExitUrl(): string | null { export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingChatUnlock = peekPendingChatUnlock(); const pendingChatUnlock = await peekPendingChatUnlock();
if (pendingChatUnlock) return pendingChatUnlock.returnUrl; if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
return null; return null;
} }
@@ -33,11 +33,11 @@ export function getSubscriptionFallbackExitUrl(
return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar; return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar;
} }
export function consumeSubscriptionExitUrl( export async function consumeSubscriptionExitUrl(
returnTo: SubscriptionReturnTo, returnTo: SubscriptionReturnTo,
): string { ): Promise<string> {
return ( return (
consumeSubscriptionExplicitExitUrl() ?? (await consumeSubscriptionExplicitExitUrl()) ??
getSubscriptionFallbackExitUrl(returnTo) getSubscriptionFallbackExitUrl(returnTo)
); );
} }
+11 -1
View File
@@ -27,9 +27,19 @@ export function PaymentSuccessSync() {
if (lastPaidKeyRef.current === paidKey) return; if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey; lastPaidKeyRef.current = paidKey;
let cancelled = false;
const syncPaymentSuccess = async () => {
userDispatch({ type: "UserFetch" }); userDispatch({ type: "UserFetch" });
if (hasPendingChatUnlock()) return; if (await hasPendingChatUnlock()) return;
if (cancelled) return;
chatDispatch({ type: "ChatPaymentSucceeded" }); chatDispatch({ type: "ChatPaymentSucceeded" });
};
void syncPaymentSuccess();
return () => {
cancelled = true;
};
}, [ }, [
chatDispatch, chatDispatch,
paymentState.currentOrderId, paymentState.currentOrderId,
+1
View File
@@ -10,5 +10,6 @@ export * from "./logger";
export * from "./platform-detect"; export * from "./platform-detect";
export * from "./pwa"; export * from "./pwa";
export * from "./result"; export * from "./result";
export * from "./session-storage";
export * from "./storage"; export * from "./storage";
export * from "./url-launcher-util"; export * from "./url-launcher-util";
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { createStorage, type Storage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory";
import sessionStorageDriver from "unstorage/drivers/session-storage";
import type { ZodType } from "zod";
import { Result, type Result as ResultT } from "./result";
/**
* SessionAsyncUtil — 会话级键值存储工具类(基于 unstorage)。
*
* 与 `SpAsyncUtil` 的 localStorage 持久化定位不同,本工具只用于当前 tab
* 会话内的临时 UI 状态,例如跨路由返回时恢复滚动位置。
*/
export class SessionAsyncUtil {
private static _storage: Storage = createDefaultSessionStorage();
private constructor() {}
static setStorage(instance: Storage): void {
SessionAsyncUtil._storage = instance;
}
static getRawStorage(): Storage {
return SessionAsyncUtil._storage;
}
static async getJson<T>(
key: string,
schema: ZodType<T>,
): Promise<ResultT<T | null>> {
try {
const value = await SessionAsyncUtil._storage.getItem<unknown>(key);
if (value === null || value === undefined) return Result.ok(null);
const json = typeof value === "string" ? JSON.parse(value) : value;
return Result.ok(schema.parse(json));
} catch (e) {
return Result.err(toError(e));
}
}
static async setJson<T>(
key: string,
value: T,
schema?: ZodType<T>,
): Promise<ResultT<void>> {
try {
const validated = schema ? schema.parse(value) : value;
await SessionAsyncUtil._storage.setItem(key, JSON.stringify(validated));
return Result.ok(undefined);
} catch (e) {
return Result.err(toError(e));
}
}
static async remove(key: string): Promise<ResultT<void>> {
try {
await SessionAsyncUtil._storage.removeItem(key);
return Result.ok(undefined);
} catch (e) {
return Result.err(toError(e));
}
}
}
function toError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}
function createDefaultSessionStorage(): Storage {
if (typeof window === "undefined") {
return createStorage({ driver: memoryDriver() });
}
try {
return createStorage({
driver: sessionStorageDriver({
base: "cozsweet:",
storage: window.sessionStorage,
window,
}),
});
} catch {
return createStorage({ driver: memoryDriver() });
}
}