diff --git a/src/app/chat/chat-scroll-session.ts b/src/app/chat/chat-scroll-session.ts index dc0b9cfa..6f09facc 100644 --- a/src/app/chat/chat-scroll-session.ts +++ b/src/app/chat/chat-scroll-session.ts @@ -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 { 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( diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index f0af8c96..3dd34ddc 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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]); diff --git a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts index 20791437..a524e3d2 100644 --- a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts +++ b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts @@ -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 { diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index 88b1df3e..e6167f5d 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -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 { diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index 3450d8da..959877b5 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -23,6 +23,9 @@ export const StorageKeys = { // chat chatHistory: "chat_history", + chatScrollSession: "chat_scroll_session", + pendingChatImageReturn: "pending_chat_image_return", + pendingChatUnlock: "pending_chat_unlock", // pwa / app info lastPwaDialogShown: "last_pwa_dialog_shown", diff --git a/src/lib/chat/chat_scroll_session_storage.ts b/src/lib/chat/chat_scroll_session_storage.ts new file mode 100644 index 00000000..21998e89 --- /dev/null +++ b/src/lib/chat/chat_scroll_session_storage.ts @@ -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; + +export class ChatScrollSessionStorage { + private constructor() {} + + static setSnapshot(snapshot: ChatScrollSession): Promise> { + return SessionAsyncUtil.setJson( + StorageKeys.chatScrollSession, + snapshot, + ChatScrollSessionSchema, + ); + } + + static getSnapshot(): Promise> { + return SessionAsyncUtil.getJson( + StorageKeys.chatScrollSession, + ChatScrollSessionSchema, + ); + } + + static clearSnapshot(): Promise> { + return SessionAsyncUtil.remove(StorageKeys.chatScrollSession); + } + + static async consumeSnapshot(): Promise> { + const result = await ChatScrollSessionStorage.getSnapshot(); + const cleared = await ChatScrollSessionStorage.clearSnapshot(); + if (Result.isErr(result)) return result; + if (Result.isErr(cleared)) return cleared; + return result; + } +} diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts index 45815005..7dd1d31c 100644 --- a/src/lib/navigation/__tests__/subscription_exit.test.ts +++ b/src/lib/navigation/__tests__/subscription_exit.test.ts @@ -35,34 +35,38 @@ describe("subscription exit helpers", () => { peekPendingChatUnlockMock.mockReset(); }); - it("uses explicit chat image return urls first", () => { - consumePendingChatImageReturnMock.mockReturnValue({ + it("uses explicit chat image return urls first", async () => { + consumePendingChatImageReturnMock.mockResolvedValue({ reason: "image_paywall", messageId: "msg_1", returnUrl: "/chat/image/msg_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", () => { - consumePendingChatImageReturnMock.mockReturnValue(null); - peekPendingChatUnlockMock.mockReturnValue(null); + it("falls back to chat when requested", async () => { + consumePendingChatImageReturnMock.mockResolvedValue(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", () => { - consumePendingChatImageReturnMock.mockReturnValue(null); - peekPendingChatUnlockMock.mockReturnValue(null); + it("falls back to sidebar by default", async () => { + consumePendingChatImageReturnMock.mockResolvedValue(null); + peekPendingChatUnlockMock.mockResolvedValue(null); 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", () => { - peekPendingChatUnlockMock.mockReturnValue({ + it("peeks chat unlock return urls without clearing them", async () => { + peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", messageId: "msg_1", kind: "image", @@ -71,13 +75,15 @@ describe("subscription exit helpers", () => { createdAt: 1, }); - expect(peekSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); + await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe( + "/chat/image/msg_1", + ); expect(clearPendingChatUnlockMock).not.toHaveBeenCalled(); }); - it("clears chat unlock return urls when consumed", () => { - consumePendingChatImageReturnMock.mockReturnValue(null); - peekPendingChatUnlockMock.mockReturnValue({ + it("clears chat unlock return urls when consumed", async () => { + consumePendingChatImageReturnMock.mockResolvedValue(null); + peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", messageId: "msg_1", kind: "image", @@ -86,7 +92,9 @@ describe("subscription exit helpers", () => { createdAt: 1, }); - expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); + await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe( + "/chat/image/msg_1", + ); expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/navigation/chat_image_return_session.ts b/src/lib/navigation/chat_image_return_session.ts index e4faf014..b17f1756 100644 --- a/src/lib/navigation/chat_image_return_session.ts +++ b/src/lib/navigation/chat_image_return_session.ts @@ -1,55 +1,54 @@ "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; -export interface PendingChatImageReturn { - reason: "image_paywall"; - messageId: string; - returnUrl: string; - createdAt: number; -} +const PendingChatImageReturnSchema = z.object({ + reason: z.literal("image_paywall"), + messageId: z.string().min(1), + returnUrl: z.string().min(1), + createdAt: z.number(), +}); -export function savePendingChatImageReturn(input: { +export type PendingChatImageReturn = z.output< + typeof PendingChatImageReturnSchema +>; + +export async function savePendingChatImageReturn(input: { messageId: string; returnUrl: string; -}): void { - if (typeof window === "undefined") return; +}): Promise { const payload: PendingChatImageReturn = { reason: "image_paywall", messageId: input.messageId, returnUrl: input.returnUrl, createdAt: Date.now(), }; - window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + await SessionAsyncUtil.setJson( + StorageKeys.pendingChatImageReturn, + payload, + PendingChatImageReturnSchema, + ); } -export function consumePendingChatImageReturn(): PendingChatImageReturn | null { - if (typeof window === "undefined") return null; - - const raw = window.sessionStorage.getItem(STORAGE_KEY); - window.sessionStorage.removeItem(STORAGE_KEY); - if (!raw) return null; - - try { - const value = JSON.parse(raw) as Partial; - 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; - - return { - reason: value.reason, - messageId: value.messageId, - returnUrl: value.returnUrl, - createdAt: value.createdAt, - }; - } catch { - return null; - } +export async function consumePendingChatImageReturn(): Promise { + const result = await SessionAsyncUtil.getJson( + StorageKeys.pendingChatImageReturn, + PendingChatImageReturnSchema, + ); + await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); + if (Result.isErr(result)) return null; + return parsePendingChatImageReturn(result.data); +} + +function parsePendingChatImageReturn( + value: PendingChatImageReturn | null, +): PendingChatImageReturn | null { + if (!value) return null; + if (Date.now() - value.createdAt > MAX_AGE_MS) return null; + return value; } diff --git a/src/lib/navigation/chat_unlock_session.ts b/src/lib/navigation/chat_unlock_session.ts index ce51c14c..3a526d3f 100644 --- a/src/lib/navigation/chat_unlock_session.ts +++ b/src/lib/navigation/chat_unlock_session.ts @@ -1,27 +1,38 @@ "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; export type PendingChatUnlockKind = "private" | "voice" | "image"; export type PendingChatUnlockStage = "auth" | "payment"; -export interface PendingChatUnlock { - reason: "single_message_unlock"; - messageId: string; - kind: PendingChatUnlockKind; - returnUrl: string; - stage: PendingChatUnlockStage; - createdAt: number; -} +const PendingChatUnlockSchema = z.object({ + reason: z.literal("single_message_unlock"), + messageId: z.string().min(1), + kind: z.enum(["private", "voice", "image"]), + returnUrl: z + .string() + .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; + +export async function savePendingChatUnlock(input: { messageId: string; kind: PendingChatUnlockKind; returnUrl: string; stage: PendingChatUnlockStage; -}): void { - if (typeof window === "undefined") return; +}): Promise { const payload: PendingChatUnlock = { reason: "single_message_unlock", messageId: input.messageId, @@ -30,80 +41,48 @@ export function savePendingChatUnlock(input: { stage: input.stage, createdAt: Date.now(), }; - window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + await SessionAsyncUtil.setJson( + StorageKeys.pendingChatUnlock, + payload, + PendingChatUnlockSchema, + ); } -export function consumePendingChatUnlock(): PendingChatUnlock | null { - if (typeof window === "undefined") return null; - - const raw = window.sessionStorage.getItem(STORAGE_KEY); - window.sessionStorage.removeItem(STORAGE_KEY); - if (!raw) return null; - - return parsePendingChatUnlock(raw); +export async function consumePendingChatUnlock(): Promise { + const result = await SessionAsyncUtil.getJson( + StorageKeys.pendingChatUnlock, + PendingChatUnlockSchema, + ); + await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); + if (Result.isErr(result)) return null; + return parsePendingChatUnlock(result.data); } -export function peekPendingChatUnlock(): PendingChatUnlock | null { - if (typeof window === "undefined") return null; - - const raw = window.sessionStorage.getItem(STORAGE_KEY); - if (!raw) return null; - - const value = parsePendingChatUnlock(raw); +export async function peekPendingChatUnlock(): Promise { + const result = await SessionAsyncUtil.getJson( + StorageKeys.pendingChatUnlock, + PendingChatUnlockSchema, + ); + if (Result.isErr(result)) return null; + const value = parsePendingChatUnlock(result.data); if (!value) { - window.sessionStorage.removeItem(STORAGE_KEY); + await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); } return value; } -export function hasPendingChatUnlock(): boolean { - return peekPendingChatUnlock() !== null; +export async function hasPendingChatUnlock(): Promise { + return (await peekPendingChatUnlock()) !== null; } -export function clearPendingChatUnlock(): void { - if (typeof window === "undefined") return; - window.sessionStorage.removeItem(STORAGE_KEY); +export async function clearPendingChatUnlock(): Promise { + await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); } -function parsePendingChatUnlock(raw: string): PendingChatUnlock | null { - try { - const value = JSON.parse(raw) as Partial; - if (value.reason !== "single_message_unlock") 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; - - 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"; +function parsePendingChatUnlock( + value: PendingChatUnlock | null, +): PendingChatUnlock | null { + if (!value) return null; + if (Date.now() - value.createdAt > MAX_AGE_MS) return null; + return value; } diff --git a/src/lib/navigation/subscription_exit.ts b/src/lib/navigation/subscription_exit.ts index 08f0cc87..4aba3134 100644 --- a/src/lib/navigation/subscription_exit.ts +++ b/src/lib/navigation/subscription_exit.ts @@ -10,19 +10,19 @@ import { export type SubscriptionReturnTo = "chat" | null; -export function consumeSubscriptionExplicitExitUrl(): string | null { - const pendingImageReturn = consumePendingChatImageReturn(); +export async function consumeSubscriptionExplicitExitUrl(): Promise { + const pendingImageReturn = await consumePendingChatImageReturn(); if (pendingImageReturn) return pendingImageReturn.returnUrl; - const pendingChatUnlock = peekPendingChatUnlock(); + const pendingChatUnlock = await peekPendingChatUnlock(); if (pendingChatUnlock) { - clearPendingChatUnlock(); + await clearPendingChatUnlock(); return pendingChatUnlock.returnUrl; } return null; } -export function peekSubscriptionExplicitExitUrl(): string | null { - const pendingChatUnlock = peekPendingChatUnlock(); +export async function peekSubscriptionExplicitExitUrl(): Promise { + const pendingChatUnlock = await peekPendingChatUnlock(); if (pendingChatUnlock) return pendingChatUnlock.returnUrl; return null; } @@ -33,11 +33,11 @@ export function getSubscriptionFallbackExitUrl( return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar; } -export function consumeSubscriptionExitUrl( +export async function consumeSubscriptionExitUrl( returnTo: SubscriptionReturnTo, -): string { +): Promise { return ( - consumeSubscriptionExplicitExitUrl() ?? + (await consumeSubscriptionExplicitExitUrl()) ?? getSubscriptionFallbackExitUrl(returnTo) ); } diff --git a/src/stores/sync/payment-success-sync.tsx b/src/stores/sync/payment-success-sync.tsx index b666498e..e1a19b3a 100644 --- a/src/stores/sync/payment-success-sync.tsx +++ b/src/stores/sync/payment-success-sync.tsx @@ -27,9 +27,19 @@ export function PaymentSuccessSync() { if (lastPaidKeyRef.current === paidKey) return; lastPaidKeyRef.current = paidKey; - userDispatch({ type: "UserFetch" }); - if (hasPendingChatUnlock()) return; - chatDispatch({ type: "ChatPaymentSucceeded" }); + let cancelled = false; + + const syncPaymentSuccess = async () => { + userDispatch({ type: "UserFetch" }); + if (await hasPendingChatUnlock()) return; + if (cancelled) return; + chatDispatch({ type: "ChatPaymentSucceeded" }); + }; + + void syncPaymentSuccess(); + return () => { + cancelled = true; + }; }, [ chatDispatch, paymentState.currentOrderId, diff --git a/src/utils/index.ts b/src/utils/index.ts index 198ab859..fe2ed4a1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,5 +10,6 @@ export * from "./logger"; export * from "./platform-detect"; export * from "./pwa"; export * from "./result"; +export * from "./session-storage"; export * from "./storage"; export * from "./url-launcher-util"; diff --git a/src/utils/session-storage.ts b/src/utils/session-storage.ts new file mode 100644 index 00000000..364ddc88 --- /dev/null +++ b/src/utils/session-storage.ts @@ -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( + key: string, + schema: ZodType, + ): Promise> { + try { + const value = await SessionAsyncUtil._storage.getItem(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( + key: string, + value: T, + schema?: ZodType, + ): Promise> { + 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> { + 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() }); + } +}