352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
|
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
|
import { getCharacterById } from "@/data/constants/character";
|
|
import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases";
|
|
import { Result } from "@/utils/result";
|
|
import { SessionAsyncUtil } from "@/utils/session-storage";
|
|
|
|
const MAX_AGE_MS = 30 * 60 * 1000;
|
|
|
|
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
|
export type PendingChatUnlockStage = "auth" | "payment";
|
|
export type PendingChatPromotionType = "voice" | "image" | "private";
|
|
|
|
export const PendingChatPromotionSchema = z.object({
|
|
characterId: z.string().min(1),
|
|
promotionType: z.enum(["voice", "image", "private"]),
|
|
lockType: ChatLockTypeSchema,
|
|
clientLockId: z.string().min(1),
|
|
createdAt: z.number(),
|
|
});
|
|
|
|
const PendingChatUnlockSchema = z
|
|
.object({
|
|
reason: z.literal("single_message_unlock"),
|
|
characterId: z.string().min(1),
|
|
displayMessageId: z.string().min(1),
|
|
messageId: z.string().min(1).optional(),
|
|
kind: z.enum(["private", "voice", "image"]),
|
|
lockType: ChatLockTypeSchema.optional(),
|
|
clientLockId: z.string().min(1).optional(),
|
|
promotion: PendingChatPromotionSchema.optional(),
|
|
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(),
|
|
})
|
|
.refine((value) => value.messageId || value.lockType, {
|
|
message: "messageId or lockType is required",
|
|
});
|
|
|
|
const PendingChatImageReturnSchema = z.object({
|
|
reason: z.literal("image_paywall"),
|
|
characterId: z.string().min(1),
|
|
messageId: z.string().min(1),
|
|
returnUrl: z
|
|
.string()
|
|
.min(1)
|
|
.refine(
|
|
(value) => value.startsWith("/") && !value.startsWith("//"),
|
|
"returnUrl must be an internal route",
|
|
),
|
|
createdAt: z.number(),
|
|
});
|
|
|
|
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
|
export type PendingChatPromotion = z.output<
|
|
typeof PendingChatPromotionSchema
|
|
>;
|
|
export type PendingChatImageReturn = z.output<
|
|
typeof PendingChatImageReturnSchema
|
|
>;
|
|
|
|
export function createPendingChatPromotion(
|
|
promotionType: PendingChatPromotionType,
|
|
characterId: string,
|
|
): PendingChatPromotion {
|
|
return PendingChatPromotionSchema.parse({
|
|
characterId,
|
|
promotionType,
|
|
lockType: toPromotionLockType(promotionType),
|
|
clientLockId: `promotion_${createUuidV4()}`,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* NavigationStorage owns short-lived cross-route sessions.
|
|
*
|
|
* UI / router code should not touch the raw session storage utility directly.
|
|
* Keeping these payloads here makes auth, payment, and chat return flows
|
|
* testable without spreading storage keys across screens.
|
|
*/
|
|
export class NavigationStorage {
|
|
private constructor() {}
|
|
|
|
static async savePendingChatUnlock(input: {
|
|
characterId: string;
|
|
displayMessageId?: string;
|
|
messageId?: string;
|
|
kind: PendingChatUnlockKind;
|
|
lockType?: PendingChatUnlock["lockType"];
|
|
clientLockId?: string;
|
|
promotion?: PendingChatPromotion;
|
|
returnUrl: string;
|
|
stage: PendingChatUnlockStage;
|
|
}): Promise<void> {
|
|
const displayMessageId =
|
|
input.displayMessageId ??
|
|
input.messageId ??
|
|
(input.clientLockId ? `promotion:${input.clientLockId}` : null);
|
|
if (!displayMessageId) {
|
|
throw new Error("displayMessageId is required");
|
|
}
|
|
if (
|
|
input.promotion &&
|
|
input.promotion.characterId !== input.characterId
|
|
) {
|
|
throw new Error("Pending promotion belongs to a different character.");
|
|
}
|
|
const payload: PendingChatUnlock = {
|
|
reason: "single_message_unlock",
|
|
characterId: input.characterId,
|
|
displayMessageId,
|
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
|
kind: input.kind,
|
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
|
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
|
...(input.promotion ? { promotion: input.promotion } : {}),
|
|
returnUrl: input.returnUrl,
|
|
stage: input.stage,
|
|
createdAt: Date.now(),
|
|
};
|
|
await SessionAsyncUtil.setJson(
|
|
StorageKeys.pendingChatUnlock,
|
|
payload,
|
|
PendingChatUnlockSchema,
|
|
);
|
|
}
|
|
|
|
static async consumePendingChatUnlock(
|
|
characterId: string,
|
|
): Promise<PendingChatUnlock | null> {
|
|
const result = await SessionAsyncUtil.getJson(
|
|
StorageKeys.pendingChatUnlock,
|
|
PendingChatUnlockSchema,
|
|
);
|
|
if (Result.isErr(result)) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
return null;
|
|
}
|
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
|
if (!value) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
return null;
|
|
}
|
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
|
return null;
|
|
}
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
return value;
|
|
}
|
|
|
|
static async peekPendingChatUnlock(
|
|
characterId: string,
|
|
): Promise<PendingChatUnlock | null> {
|
|
const result = await SessionAsyncUtil.getJson(
|
|
StorageKeys.pendingChatUnlock,
|
|
PendingChatUnlockSchema,
|
|
);
|
|
if (Result.isErr(result)) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
return null;
|
|
}
|
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
|
if (!value) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
}
|
|
return value?.characterId === normalizeLegacyCharacterId(characterId)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
|
return (
|
|
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
|
|
);
|
|
}
|
|
|
|
static async clearPendingChatUnlock(): Promise<void> {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
|
}
|
|
|
|
static async savePendingChatPromotion(
|
|
promotionType: PendingChatPromotionType,
|
|
characterId: string,
|
|
): Promise<PendingChatPromotion> {
|
|
const promotion = createPendingChatPromotion(
|
|
promotionType,
|
|
characterId,
|
|
);
|
|
await SessionAsyncUtil.setJson(
|
|
StorageKeys.pendingChatPromotion,
|
|
promotion,
|
|
PendingChatPromotionSchema,
|
|
);
|
|
return promotion;
|
|
}
|
|
|
|
static async consumePendingChatPromotion(
|
|
characterId: string,
|
|
): Promise<PendingChatPromotion | null> {
|
|
const result = await SessionAsyncUtil.getJson(
|
|
StorageKeys.pendingChatPromotion,
|
|
PendingChatPromotionSchema,
|
|
);
|
|
if (Result.isErr(result)) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
|
return null;
|
|
}
|
|
const value = NavigationStorage.parsePendingChatPromotion(result.data);
|
|
if (!value) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
|
return null;
|
|
}
|
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
|
return null;
|
|
}
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
|
return value;
|
|
}
|
|
|
|
static async clearPendingChatPromotion(): Promise<void> {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
|
}
|
|
|
|
static async savePendingChatImageReturn(input: {
|
|
characterId: string;
|
|
messageId: string;
|
|
returnUrl: string;
|
|
}): Promise<void> {
|
|
const payload: PendingChatImageReturn = {
|
|
reason: "image_paywall",
|
|
characterId: input.characterId,
|
|
messageId: input.messageId,
|
|
returnUrl: input.returnUrl,
|
|
createdAt: Date.now(),
|
|
};
|
|
await SessionAsyncUtil.setJson(
|
|
StorageKeys.pendingChatImageReturn,
|
|
payload,
|
|
PendingChatImageReturnSchema,
|
|
);
|
|
}
|
|
|
|
static async consumePendingChatImageReturn(
|
|
characterId: string,
|
|
): Promise<PendingChatImageReturn | null> {
|
|
const result = await SessionAsyncUtil.getJson(
|
|
StorageKeys.pendingChatImageReturn,
|
|
PendingChatImageReturnSchema,
|
|
);
|
|
if (Result.isErr(result)) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
|
return null;
|
|
}
|
|
const value = NavigationStorage.parsePendingChatImageReturn(result.data);
|
|
if (!value) {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
|
return null;
|
|
}
|
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
|
return null;
|
|
}
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
|
return value;
|
|
}
|
|
|
|
static async clearPendingChatImageReturn(): Promise<void> {
|
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
|
}
|
|
|
|
private static parsePendingChatUnlock(
|
|
value: PendingChatUnlock | null,
|
|
): PendingChatUnlock | null {
|
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
|
if (!getCharacterById(characterId)) return null;
|
|
const promotion = value.promotion
|
|
? NavigationStorage.parsePendingChatPromotion(value.promotion)
|
|
: undefined;
|
|
if (value.promotion && !promotion) return null;
|
|
return PendingChatUnlockSchema.parse({
|
|
...value,
|
|
characterId,
|
|
...(promotion ? { promotion } : {}),
|
|
});
|
|
}
|
|
|
|
private static parsePendingChatImageReturn(
|
|
value: PendingChatImageReturn | null,
|
|
): PendingChatImageReturn | null {
|
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
|
return getCharacterById(characterId)
|
|
? PendingChatImageReturnSchema.parse({ ...value, characterId })
|
|
: null;
|
|
}
|
|
|
|
private static parsePendingChatPromotion(
|
|
value: PendingChatPromotion | null,
|
|
): PendingChatPromotion | null {
|
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
|
return getCharacterById(characterId)
|
|
? PendingChatPromotionSchema.parse({ ...value, characterId })
|
|
: null;
|
|
}
|
|
}
|
|
|
|
function toPromotionLockType(
|
|
promotionType: PendingChatPromotionType,
|
|
): PendingChatPromotion["lockType"] {
|
|
if (promotionType === "voice") return "voice_message";
|
|
if (promotionType === "image") return "image_paywall";
|
|
return "private_message";
|
|
}
|
|
|
|
function createUuidV4(): string {
|
|
const cryptoApi = globalThis.crypto;
|
|
if (typeof cryptoApi?.randomUUID === "function") {
|
|
return cryptoApi.randomUUID();
|
|
}
|
|
|
|
const bytes = new Uint8Array(16);
|
|
if (typeof cryptoApi?.getRandomValues === "function") {
|
|
cryptoApi.getRandomValues(bytes);
|
|
} else {
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
bytes[index] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
return [
|
|
hex.slice(0, 4).join(""),
|
|
hex.slice(4, 6).join(""),
|
|
hex.slice(6, 8).join(""),
|
|
hex.slice(8, 10).join(""),
|
|
hex.slice(10, 16).join(""),
|
|
].join("-");
|
|
}
|