refactor(storage): use unstorage for session state
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user