fix(chat): isolate pending flows and cancel stale requests
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import type {
|
||||
ChatRequestOptions,
|
||||
ChatSendOptions,
|
||||
UnlockPrivateMessageInput,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
@@ -17,7 +21,7 @@ export class ChatRemoteDataSource {
|
||||
async sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
options?: ChatSendOptions,
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const request = SendMessageRequestSchema.parse({
|
||||
@@ -26,7 +30,7 @@ export class ChatRemoteDataSource {
|
||||
image: options?.image ?? "",
|
||||
useWebSocket: options?.useWebSocket ?? false,
|
||||
});
|
||||
return await this.api.sendMessage(request);
|
||||
return await this.api.sendMessage(request, options);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,23 +38,34 @@ export class ChatRemoteDataSource {
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
|
||||
return Result.wrap(() =>
|
||||
this.api.getHistory(characterId, limit, offset, options),
|
||||
);
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
|
||||
this.api.unlockPrivateMessage(
|
||||
UnlockPrivateRequestSchema.parse(input),
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async unlockHistory(
|
||||
characterId: string,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
|
||||
this.api.unlockHistory(
|
||||
UnlockHistoryRequestSchema.parse({ characterId }),
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
ChatRequestOptions,
|
||||
ChatSendOptions,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
@@ -38,7 +40,7 @@ export class ChatRepository implements IChatRepository {
|
||||
async sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
options?: ChatSendOptions,
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return this.remote.sendMessage(characterId, message, options);
|
||||
}
|
||||
@@ -48,22 +50,25 @@ export class ChatRepository implements IChatRepository {
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return this.remote.getHistory(characterId, limit, offset);
|
||||
return this.remote.getHistory(characterId, limit, offset, options);
|
||||
}
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return this.remote.unlockPrivateMessage(input);
|
||||
return this.remote.unlockPrivateMessage(input, options);
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
async unlockHistory(
|
||||
characterId: string,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockHistoryResponse>> {
|
||||
return this.remote.unlockHistory(characterId);
|
||||
return this.remote.unlockHistory(characterId, options);
|
||||
}
|
||||
|
||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||
|
||||
@@ -42,12 +42,21 @@ export interface UnlockPrivateMessageInput {
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface ChatRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ChatSendOptions extends ChatRequestOptions {
|
||||
image?: string;
|
||||
useWebSocket?: boolean;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
options?: ChatSendOptions,
|
||||
): Promise<Result<ChatSendResponse>>;
|
||||
|
||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||
@@ -55,15 +64,20 @@ export interface IChatRepository {
|
||||
characterId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockPrivateResponse>>;
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
|
||||
unlockHistory(
|
||||
characterId: string,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<UnlockHistoryResponse>>;
|
||||
|
||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||
markPrivateMessageUnlockedInLocal(
|
||||
|
||||
@@ -59,6 +59,23 @@ describe("multi-character API contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards request cancellation to the HTTP client", async () => {
|
||||
const controller = new AbortController();
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: { messages: [], total: 0, limit: 50, offset: 0 },
|
||||
});
|
||||
|
||||
await new ChatApi().getHistory(CHARACTER_ID, 50, 0, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
|
||||
query: { characterId: CHARACTER_ID, limit: 50, offset: 0 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends characterId with private and history unlocks", async () => {
|
||||
const api = new ChatApi();
|
||||
httpClientMock
|
||||
|
||||
@@ -25,10 +25,14 @@ export class ChatApi {
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
|
||||
async sendMessage(
|
||||
body: SendMessageRequest,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<ChatSendResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
||||
method: "POST",
|
||||
body,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
@@ -40,9 +44,11 @@ export class ChatApi {
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<ChatHistoryResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||
query: { characterId, limit, offset },
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
return ChatHistoryResponseSchema.parse(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
@@ -54,12 +60,14 @@ export class ChatApi {
|
||||
*/
|
||||
async unlockPrivateMessage(
|
||||
body: UnlockPrivateRequest,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<UnlockPrivateResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.chatUnlockPrivate,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
);
|
||||
return UnlockPrivateResponseSchema.parse(
|
||||
@@ -72,12 +80,14 @@ export class ChatApi {
|
||||
*/
|
||||
async unlockHistory(
|
||||
body: UnlockHistoryRequest,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<UnlockHistoryResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.chatUnlockHistory,
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
);
|
||||
return UnlockHistoryResponseSchema.parse(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||
|
||||
import { NavigationStorage } from "../navigation_storage";
|
||||
|
||||
const CHARACTER_ID = "character_elio";
|
||||
const OTHER_CHARACTER_ID = "character_maya";
|
||||
|
||||
describe("NavigationStorage", () => {
|
||||
beforeEach(() => {
|
||||
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||
@@ -14,13 +17,7 @@ describe("NavigationStorage", () => {
|
||||
|
||||
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
@@ -28,12 +25,25 @@ describe("NavigationStorage", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatUnlock(),
|
||||
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
await expect(
|
||||
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("drops expired pending chat unlock sessions", async () => {
|
||||
@@ -41,6 +51,7 @@ describe("NavigationStorage", () => {
|
||||
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
@@ -49,25 +60,32 @@ describe("NavigationStorage", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
||||
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
await expect(
|
||||
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||
"image",
|
||||
CHARACTER_ID,
|
||||
);
|
||||
|
||||
expect(promotion).toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||
).resolves.toEqual(promotion);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
characterId: CHARACTER_ID,
|
||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||
kind: "image",
|
||||
lockType: promotion.lockType,
|
||||
@@ -76,7 +94,10 @@ describe("NavigationStorage", () => {
|
||||
returnUrl: "/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
await expect(
|
||||
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
lockType: "image_paywall",
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
@@ -85,18 +106,62 @@ describe("NavigationStorage", () => {
|
||||
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||
).resolves.toMatchObject({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
});
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("leaves another character's pending navigation state untouched", async () => {
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||
"voice",
|
||||
CHARACTER_ID,
|
||||
);
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
characterId: CHARACTER_ID,
|
||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||
kind: "voice",
|
||||
lockType: promotion.lockType,
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
returnUrl: "/characters/elio/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/characters/elio/chat?image=msg_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(OTHER_CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatUnlock(OTHER_CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(OTHER_CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||
).resolves.toEqual(promotion);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ 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),
|
||||
@@ -23,6 +24,7 @@ export const PendingChatPromotionSchema = z.object({
|
||||
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"]),
|
||||
@@ -45,6 +47,7 @@ const PendingChatUnlockSchema = z
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
characterId: z.string().min(1),
|
||||
messageId: z.string().min(1),
|
||||
returnUrl: z
|
||||
.string()
|
||||
@@ -75,6 +78,7 @@ export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
characterId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
@@ -91,8 +95,15 @@ export class NavigationStorage {
|
||||
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,
|
||||
@@ -110,31 +121,49 @@ export class NavigationStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
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 !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
static async peekPendingChatUnlock(
|
||||
characterId: string,
|
||||
): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) return null;
|
||||
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;
|
||||
return value?.characterId === characterId ? value : null;
|
||||
}
|
||||
|
||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
||||
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||
return (
|
||||
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
|
||||
);
|
||||
}
|
||||
|
||||
static async clearPendingChatUnlock(): Promise<void> {
|
||||
@@ -143,8 +172,10 @@ export class NavigationStorage {
|
||||
|
||||
static async savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
characterId: string,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
characterId,
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
@@ -158,14 +189,25 @@ export class NavigationStorage {
|
||||
return promotion;
|
||||
}
|
||||
|
||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
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 !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
static async clearPendingChatPromotion(): Promise<void> {
|
||||
@@ -173,11 +215,13 @@ export class NavigationStorage {
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -189,14 +233,25 @@ export class NavigationStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||
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 !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
|
||||
Reference in New Issue
Block a user