fix(chat): isolate pending flows and cancel stale requests

This commit is contained in:
2026-07-17 19:22:01 +08:00
parent 2fc312b5c7
commit 8bb1e21886
27 changed files with 501 additions and 92 deletions
@@ -20,6 +20,7 @@ const defaultScope = {
}; };
const promotionSession: PendingChatPromotion = { const promotionSession: PendingChatPromotion = {
characterId: "character_elio",
promotionType: "image", promotionType: "image",
lockType: "image_paywall", lockType: "image_paywall",
clientLockId: "promotion-1", clientLockId: "promotion-1",
@@ -181,6 +182,7 @@ function createPendingUnlock(
): PendingChatUnlock { ): PendingChatUnlock {
return { return {
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
...input, ...input,
stage: "auth", stage: "auth",
createdAt: 1, createdAt: 1,
+3 -1
View File
@@ -58,7 +58,9 @@ export function ChatScreen() {
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
messages: state.historyMessages, messages: state.historyMessages,
}); });
const isPromotionBootstrapReady = useChatPromotionBootstrap(); const isPromotionBootstrapReady = useChatPromotionBootstrap(
state.characterId,
);
const visibleMessages = isPromotionBootstrapReady const visibleMessages = isPromotionBootstrapReady
? state.messages ? state.messages
: state.historyMessages; : state.historyMessages;
@@ -11,7 +11,7 @@ import { Logger } from "@/utils/logger";
const log = new Logger("UseChatPromotionBootstrap"); const log = new Logger("UseChatPromotionBootstrap");
export function useChatPromotionBootstrap(): boolean { export function useChatPromotionBootstrap(characterId: string): boolean {
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const startedRef = useRef(false); const startedRef = useRef(false);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
@@ -23,8 +23,8 @@ export function useChatPromotionBootstrap(): boolean {
void (async () => { void (async () => {
try { try {
const [entryPromotion, pendingUnlock] = await Promise.all([ const [entryPromotion, pendingUnlock] = await Promise.all([
consumePendingChatPromotion(), consumePendingChatPromotion(characterId),
peekPendingChatUnlock(), peekPendingChatUnlock(characterId),
]); ]);
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null; const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
@@ -46,7 +46,7 @@ export function useChatPromotionBootstrap(): boolean {
setIsReady(true); setIsReady(true);
} }
})(); })();
}, [chatDispatch]); }, [characterId, chatDispatch]);
return isReady; return isReady;
} }
@@ -79,6 +79,7 @@ export function useChatUnlockCoordinator({
const trackedPaywallRef = useRef<string | null>(null); const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector( const chatState = useChatSelector(
(state) => ({ (state) => ({
characterId: state.context.characterId,
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount, lockedHistoryCount: state.context.lockedHistoryCount,
@@ -125,7 +126,7 @@ export function useChatUnlockCoordinator({
let cancelled = false; let cancelled = false;
const resumePendingUnlock = async () => { const resumePendingUnlock = async () => {
const pending = await peekPendingChatUnlock(); const pending = await peekPendingChatUnlock(chatState.characterId);
if ( if (
cancelled || cancelled ||
!pending || !pending ||
@@ -138,7 +139,7 @@ export function useChatUnlockCoordinator({
return; return;
} }
const consumed = await consumePendingChatUnlock(); const consumed = await consumePendingChatUnlock(chatState.characterId);
if (cancelled || !consumed) return; if (cancelled || !consumed) return;
chatDispatch({ chatDispatch({
@@ -157,6 +158,7 @@ export function useChatUnlockCoordinator({
}; };
}, [ }, [
chatDispatch, chatDispatch,
chatState.characterId,
chatState.historyLoaded, chatState.historyLoaded,
defaultReturnUrl, defaultReturnUrl,
enabled, enabled,
@@ -15,6 +15,10 @@ import {
savePendingChatPromotion, savePendingChatPromotion,
} from "@/lib/navigation/chat_unlock_session"; } from "@/lib/navigation/chat_unlock_session";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import {
DEFAULT_CHARACTER_ID,
getCharacterBySlug,
} from "@/data/constants/character";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
@@ -49,6 +53,8 @@ export default function ExternalEntryPersist({
const hasReinitializedForPsidRef = useRef(false); const hasReinitializedForPsidRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target }); const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, character }); const destination = resolveExternalEntryDestination({ target, character });
const characterId =
getCharacterBySlug(character)?.id ?? DEFAULT_CHARACTER_ID;
const resolvedPromotionType = resolveExternalEntryPromotionType({ const resolvedPromotionType = resolveExternalEntryPromotionType({
mode, mode,
promotionType, promotionType,
@@ -66,7 +72,7 @@ export default function ExternalEntryPersist({
avatarUrl, avatarUrl,
}), }),
targetRoute === ROUTES.chat && resolvedPromotionType targetRoute === ROUTES.chat && resolvedPromotionType
? savePendingChatPromotion(resolvedPromotionType) ? savePendingChatPromotion(resolvedPromotionType, characterId)
: clearPendingChatPromotion(), : clearPendingChatPromotion(),
]); ]);
for (const result of results) { for (const result of results) {
@@ -87,6 +93,7 @@ export default function ExternalEntryPersist({
avatarUrl, avatarUrl,
asid, asid,
character, character,
characterId,
destination, destination,
deviceId, deviceId,
mode, mode,
@@ -1,4 +1,8 @@
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces"; import type {
ChatRequestOptions,
ChatSendOptions,
UnlockPrivateMessageInput,
} from "@/data/repositories/interfaces";
import { import {
ChatHistoryResponse, ChatHistoryResponse,
ChatSendResponse, ChatSendResponse,
@@ -17,7 +21,7 @@ export class ChatRemoteDataSource {
async sendMessage( async sendMessage(
characterId: string, characterId: string,
message: string, message: string,
options?: { image?: string; useWebSocket?: boolean }, options?: ChatSendOptions,
): Promise<Result<ChatSendResponse>> { ): Promise<Result<ChatSendResponse>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const request = SendMessageRequestSchema.parse({ const request = SendMessageRequestSchema.parse({
@@ -26,7 +30,7 @@ export class ChatRemoteDataSource {
image: options?.image ?? "", image: options?.image ?? "",
useWebSocket: options?.useWebSocket ?? false, 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, characterId: string,
limit = 50, limit = 50,
offset = 0, offset = 0,
options?: ChatRequestOptions,
): Promise<Result<ChatHistoryResponse>> { ): Promise<Result<ChatHistoryResponse>> {
return Result.wrap(() => this.api.getHistory(characterId, limit, offset)); return Result.wrap(() =>
this.api.getHistory(characterId, limit, offset, options),
);
} }
async unlockPrivateMessage( async unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
options?: ChatRequestOptions,
): Promise<Result<UnlockPrivateResponse>> { ): Promise<Result<UnlockPrivateResponse>> {
return Result.wrap(() => return Result.wrap(() =>
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)), this.api.unlockPrivateMessage(
UnlockPrivateRequestSchema.parse(input),
options,
),
); );
} }
async unlockHistory( async unlockHistory(
characterId: string, characterId: string,
options?: ChatRequestOptions,
): Promise<Result<UnlockHistoryResponse>> { ): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() => return Result.wrap(() =>
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })), this.api.unlockHistory(
UnlockHistoryRequestSchema.parse({ characterId }),
options,
),
); );
} }
} }
+9 -4
View File
@@ -1,6 +1,8 @@
import type { import type {
CacheRemoteChatMediaInput, CacheRemoteChatMediaInput,
ChatMediaLookupInput, ChatMediaLookupInput,
ChatRequestOptions,
ChatSendOptions,
IChatRepository, IChatRepository,
UnlockPrivateMessageInput, UnlockPrivateMessageInput,
UnlockedPrivateMessageLocalPatch, UnlockedPrivateMessageLocalPatch,
@@ -38,7 +40,7 @@ export class ChatRepository implements IChatRepository {
async sendMessage( async sendMessage(
characterId: string, characterId: string,
message: string, message: string,
options?: { image?: string; useWebSocket?: boolean }, options?: ChatSendOptions,
): Promise<Result<ChatSendResponse>> { ): Promise<Result<ChatSendResponse>> {
return this.remote.sendMessage(characterId, message, options); return this.remote.sendMessage(characterId, message, options);
} }
@@ -48,22 +50,25 @@ export class ChatRepository implements IChatRepository {
characterId: string, characterId: string,
limit = 50, limit = 50,
offset = 0, offset = 0,
options?: ChatRequestOptions,
): Promise<Result<ChatHistoryResponse>> { ): Promise<Result<ChatHistoryResponse>> {
return this.remote.getHistory(characterId, limit, offset); return this.remote.getHistory(characterId, limit, offset, options);
} }
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
async unlockPrivateMessage( async unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
options?: ChatRequestOptions,
): Promise<Result<UnlockPrivateResponse>> { ): Promise<Result<UnlockPrivateResponse>> {
return this.remote.unlockPrivateMessage(input); return this.remote.unlockPrivateMessage(input, options);
} }
/** 一键解锁历史锁定消息。 */ /** 一键解锁历史锁定消息。 */
async unlockHistory( async unlockHistory(
characterId: string, characterId: string,
options?: ChatRequestOptions,
): Promise<Result<UnlockHistoryResponse>> { ): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory(characterId); return this.remote.unlockHistory(characterId, options);
} }
/** 把本地缓存中的单条锁定消息标记为已解锁。 */ /** 把本地缓存中的单条锁定消息标记为已解锁。 */
@@ -42,12 +42,21 @@ export interface UnlockPrivateMessageInput {
clientLockId?: string; clientLockId?: string;
} }
export interface ChatRequestOptions {
signal?: AbortSignal;
}
export interface ChatSendOptions extends ChatRequestOptions {
image?: string;
useWebSocket?: boolean;
}
export interface IChatRepository { export interface IChatRepository {
/** 发送一条消息。 */ /** 发送一条消息。 */
sendMessage( sendMessage(
characterId: string, characterId: string,
message: string, message: string,
options?: { image?: string; useWebSocket?: boolean }, options?: ChatSendOptions,
): Promise<Result<ChatSendResponse>>; ): Promise<Result<ChatSendResponse>>;
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
@@ -55,15 +64,20 @@ export interface IChatRepository {
characterId: string, characterId: string,
limit?: number, limit?: number,
offset?: number, offset?: number,
options?: ChatRequestOptions,
): Promise<Result<ChatHistoryResponse>>; ): Promise<Result<ChatHistoryResponse>>;
/** 解锁单条历史付费 / 私密消息。 */ /** 解锁单条历史付费 / 私密消息。 */
unlockPrivateMessage( unlockPrivateMessage(
input: UnlockPrivateMessageInput, input: UnlockPrivateMessageInput,
options?: ChatRequestOptions,
): Promise<Result<UnlockPrivateResponse>>; ): Promise<Result<UnlockPrivateResponse>>;
/** 一键解锁历史锁定消息。 */ /** 一键解锁历史锁定消息。 */
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>; unlockHistory(
characterId: string,
options?: ChatRequestOptions,
): Promise<Result<UnlockHistoryResponse>>;
/** 把本地缓存中的单条锁定消息标记为已解锁。 */ /** 把本地缓存中的单条锁定消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal( 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 () => { it("sends characterId with private and history unlocks", async () => {
const api = new ChatApi(); const api = new ChatApi();
httpClientMock httpClientMock
+11 -1
View File
@@ -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, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
method: "POST", method: "POST",
body, body,
...(options?.signal ? { signal: options.signal } : {}),
}); });
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>); return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
} }
@@ -40,9 +44,11 @@ export class ChatApi {
characterId: string, characterId: string,
limit = 50, limit = 50,
offset = 0, offset = 0,
options?: { signal?: AbortSignal },
): Promise<ChatHistoryResponse> { ): Promise<ChatHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, { const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { characterId, limit, offset }, query: { characterId, limit, offset },
...(options?.signal ? { signal: options.signal } : {}),
}); });
return ChatHistoryResponseSchema.parse( return ChatHistoryResponseSchema.parse(
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
@@ -54,12 +60,14 @@ export class ChatApi {
*/ */
async unlockPrivateMessage( async unlockPrivateMessage(
body: UnlockPrivateRequest, body: UnlockPrivateRequest,
options?: { signal?: AbortSignal },
): Promise<UnlockPrivateResponse> { ): Promise<UnlockPrivateResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUnlockPrivate, ApiPath.chatUnlockPrivate,
{ {
method: "POST", method: "POST",
body, body,
...(options?.signal ? { signal: options.signal } : {}),
}, },
); );
return UnlockPrivateResponseSchema.parse( return UnlockPrivateResponseSchema.parse(
@@ -72,12 +80,14 @@ export class ChatApi {
*/ */
async unlockHistory( async unlockHistory(
body: UnlockHistoryRequest, body: UnlockHistoryRequest,
options?: { signal?: AbortSignal },
): Promise<UnlockHistoryResponse> { ): Promise<UnlockHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>( const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUnlockHistory, ApiPath.chatUnlockHistory,
{ {
method: "POST", method: "POST",
body, body,
...(options?.signal ? { signal: options.signal } : {}),
}, },
); );
return UnlockHistoryResponseSchema.parse( return UnlockHistoryResponseSchema.parse(
@@ -6,6 +6,9 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
import { NavigationStorage } from "../navigation_storage"; import { NavigationStorage } from "../navigation_storage";
const CHARACTER_ID = "character_elio";
const OTHER_CHARACTER_ID = "character_maya";
describe("NavigationStorage", () => { describe("NavigationStorage", () => {
beforeEach(() => { beforeEach(() => {
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
@@ -14,13 +17,7 @@ describe("NavigationStorage", () => {
it("saves, peeks, and consumes pending chat unlock sessions", async () => { it("saves, peeks, and consumes pending chat unlock sessions", async () => {
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
messageId: "msg_1", characterId: CHARACTER_ID,
kind: "image",
returnUrl: "/chat?image=msg_1",
stage: "payment",
});
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
returnUrl: "/chat?image=msg_1", returnUrl: "/chat?image=msg_1",
@@ -28,12 +25,25 @@ describe("NavigationStorage", () => {
}); });
await expect( await expect(
NavigationStorage.consumePendingChatUnlock(), NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
).resolves.toMatchObject({ ).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", messageId: "msg_1",
kind: "image", kind: "image",
}); });
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull(); await expect(
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
).resolves.toBeNull();
}); });
it("drops expired pending chat unlock sessions", async () => { it("drops expired pending chat unlock sessions", async () => {
@@ -41,6 +51,7 @@ describe("NavigationStorage", () => {
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z")); vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
characterId: CHARACTER_ID,
messageId: "msg_1", messageId: "msg_1",
kind: "private", kind: "private",
returnUrl: "/chat", returnUrl: "/chat",
@@ -49,25 +60,32 @@ describe("NavigationStorage", () => {
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z")); 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 () => { 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({ expect(promotion).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "image", promotionType: "image",
lockType: "image_paywall", lockType: "image_paywall",
}); });
expect(promotion.clientLockId).toMatch(/^promotion_/); expect(promotion.clientLockId).toMatch(/^promotion_/);
await expect( await expect(
NavigationStorage.consumePendingChatPromotion(), NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toEqual(promotion); ).resolves.toEqual(promotion);
await expect( await expect(
NavigationStorage.consumePendingChatPromotion(), NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toBeNull(); ).resolves.toBeNull();
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
characterId: CHARACTER_ID,
displayMessageId: `promotion:${promotion.clientLockId}`, displayMessageId: `promotion:${promotion.clientLockId}`,
kind: "image", kind: "image",
lockType: promotion.lockType, lockType: promotion.lockType,
@@ -76,7 +94,10 @@ describe("NavigationStorage", () => {
returnUrl: "/chat", returnUrl: "/chat",
stage: "auth", stage: "auth",
}); });
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({ await expect(
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
).resolves.toMatchObject({
characterId: CHARACTER_ID,
lockType: "image_paywall", lockType: "image_paywall",
clientLockId: promotion.clientLockId, clientLockId: promotion.clientLockId,
promotion, promotion,
@@ -85,18 +106,62 @@ describe("NavigationStorage", () => {
it("saves and consumes pending chat image return sessions", async () => { it("saves and consumes pending chat image return sessions", async () => {
await NavigationStorage.savePendingChatImageReturn({ await NavigationStorage.savePendingChatImageReturn({
characterId: CHARACTER_ID,
messageId: "msg_1", messageId: "msg_1",
returnUrl: "/chat?image=msg_1", returnUrl: "/chat?image=msg_1",
}); });
await expect( await expect(
NavigationStorage.consumePendingChatImageReturn(), NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
).resolves.toMatchObject({ ).resolves.toMatchObject({
characterId: CHARACTER_ID,
messageId: "msg_1", messageId: "msg_1",
returnUrl: "/chat?image=msg_1", returnUrl: "/chat?image=msg_1",
}); });
await expect( await expect(
NavigationStorage.consumePendingChatImageReturn(), NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
).resolves.toBeNull(); ).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 type PendingChatPromotionType = "voice" | "image" | "private";
export const PendingChatPromotionSchema = z.object({ export const PendingChatPromotionSchema = z.object({
characterId: z.string().min(1),
promotionType: z.enum(["voice", "image", "private"]), promotionType: z.enum(["voice", "image", "private"]),
lockType: ChatLockTypeSchema, lockType: ChatLockTypeSchema,
clientLockId: z.string().min(1), clientLockId: z.string().min(1),
@@ -23,6 +24,7 @@ export const PendingChatPromotionSchema = z.object({
const PendingChatUnlockSchema = z const PendingChatUnlockSchema = z
.object({ .object({
reason: z.literal("single_message_unlock"), reason: z.literal("single_message_unlock"),
characterId: z.string().min(1),
displayMessageId: z.string().min(1), displayMessageId: z.string().min(1),
messageId: z.string().min(1).optional(), messageId: z.string().min(1).optional(),
kind: z.enum(["private", "voice", "image"]), kind: z.enum(["private", "voice", "image"]),
@@ -45,6 +47,7 @@ const PendingChatUnlockSchema = z
const PendingChatImageReturnSchema = z.object({ const PendingChatImageReturnSchema = z.object({
reason: z.literal("image_paywall"), reason: z.literal("image_paywall"),
characterId: z.string().min(1),
messageId: z.string().min(1), messageId: z.string().min(1),
returnUrl: z returnUrl: z
.string() .string()
@@ -75,6 +78,7 @@ export class NavigationStorage {
private constructor() {} private constructor() {}
static async savePendingChatUnlock(input: { static async savePendingChatUnlock(input: {
characterId: string;
displayMessageId?: string; displayMessageId?: string;
messageId?: string; messageId?: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
@@ -91,8 +95,15 @@ export class NavigationStorage {
if (!displayMessageId) { if (!displayMessageId) {
throw new Error("displayMessageId is required"); 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 = { const payload: PendingChatUnlock = {
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: input.characterId,
displayMessageId, displayMessageId,
...(input.messageId ? { messageId: input.messageId } : {}), ...(input.messageId ? { messageId: input.messageId } : {}),
kind: input.kind, 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( const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock, StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema, 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); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
if (Result.isErr(result)) return null; return value;
return NavigationStorage.parsePendingChatUnlock(result.data);
} }
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> { static async peekPendingChatUnlock(
characterId: string,
): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson( const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock, StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema, PendingChatUnlockSchema,
); );
if (Result.isErr(result)) return null; if (Result.isErr(result)) {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
return null;
}
const value = NavigationStorage.parsePendingChatUnlock(result.data); const value = NavigationStorage.parsePendingChatUnlock(result.data);
if (!value) { if (!value) {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
} }
return value; return value?.characterId === characterId ? value : null;
} }
static async hasPendingChatUnlock(): Promise<boolean> { static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
return (await NavigationStorage.peekPendingChatUnlock()) !== null; return (
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
);
} }
static async clearPendingChatUnlock(): Promise<void> { static async clearPendingChatUnlock(): Promise<void> {
@@ -143,8 +172,10 @@ export class NavigationStorage {
static async savePendingChatPromotion( static async savePendingChatPromotion(
promotionType: PendingChatPromotionType, promotionType: PendingChatPromotionType,
characterId: string,
): Promise<PendingChatPromotion> { ): Promise<PendingChatPromotion> {
const promotion: PendingChatPromotion = { const promotion: PendingChatPromotion = {
characterId,
promotionType, promotionType,
lockType: toPromotionLockType(promotionType), lockType: toPromotionLockType(promotionType),
clientLockId: `promotion_${createUuidV4()}`, clientLockId: `promotion_${createUuidV4()}`,
@@ -158,14 +189,25 @@ export class NavigationStorage {
return promotion; return promotion;
} }
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> { static async consumePendingChatPromotion(
characterId: string,
): Promise<PendingChatPromotion | null> {
const result = await SessionAsyncUtil.getJson( const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatPromotion, StorageKeys.pendingChatPromotion,
PendingChatPromotionSchema, 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); await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
if (Result.isErr(result)) return null; return value;
return NavigationStorage.parsePendingChatPromotion(result.data);
} }
static async clearPendingChatPromotion(): Promise<void> { static async clearPendingChatPromotion(): Promise<void> {
@@ -173,11 +215,13 @@ export class NavigationStorage {
} }
static async savePendingChatImageReturn(input: { static async savePendingChatImageReturn(input: {
characterId: string;
messageId: string; messageId: string;
returnUrl: string; returnUrl: string;
}): Promise<void> { }): Promise<void> {
const payload: PendingChatImageReturn = { const payload: PendingChatImageReturn = {
reason: "image_paywall", reason: "image_paywall",
characterId: input.characterId,
messageId: input.messageId, messageId: input.messageId,
returnUrl: input.returnUrl, returnUrl: input.returnUrl,
createdAt: Date.now(), 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( const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatImageReturn, StorageKeys.pendingChatImageReturn,
PendingChatImageReturnSchema, 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); await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
if (Result.isErr(result)) return null; return value;
return NavigationStorage.parsePendingChatImageReturn(result.data);
} }
private static parsePendingChatUnlock( private static parsePendingChatUnlock(
@@ -39,6 +39,7 @@ describe("subscription exit helpers", () => {
it("uses explicit chat image return urls first", async () => { it("uses explicit chat image return urls first", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({ consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall", reason: "image_paywall",
characterId: "character_elio",
messageId: "msg_1", messageId: "msg_1",
returnUrl: "/chat?image=msg_1", returnUrl: "/chat?image=msg_1",
createdAt: 1, createdAt: 1,
@@ -73,12 +74,14 @@ describe("subscription exit helpers", () => {
it("returns directly to private room without consuming chat state", async () => { it("returns directly to private room without consuming chat state", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({ consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall", reason: "image_paywall",
characterId: "character_elio",
messageId: "msg_1", messageId: "msg_1",
returnUrl: "/chat?image=msg_1", returnUrl: "/chat?image=msg_1",
createdAt: 1, createdAt: 1,
}); });
peekPendingChatUnlockMock.mockResolvedValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1", displayMessageId: "msg_1",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -101,6 +104,7 @@ describe("subscription exit helpers", () => {
it("keeps private room ahead of stale chat state after payment", async () => { it("keeps private room ahead of stale chat state after payment", async () => {
peekPendingChatUnlockMock.mockResolvedValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1", displayMessageId: "msg_1",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -119,6 +123,7 @@ describe("subscription exit helpers", () => {
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => { it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
peekPendingChatUnlockMock.mockResolvedValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1", displayMessageId: "msg_1",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -136,6 +141,7 @@ describe("subscription exit helpers", () => {
it("peeks chat unlock return urls without clearing them", async () => { it("peeks chat unlock return urls without clearing them", async () => {
peekPendingChatUnlockMock.mockResolvedValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1", displayMessageId: "msg_1",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -154,6 +160,7 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null); consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue({ peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock", reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1", displayMessageId: "msg_1",
messageId: "msg_1", messageId: "msg_1",
kind: "image", kind: "image",
@@ -8,12 +8,15 @@ import {
export type { PendingChatImageReturn }; export type { PendingChatImageReturn };
export async function savePendingChatImageReturn(input: { export async function savePendingChatImageReturn(input: {
characterId: string;
messageId: string; messageId: string;
returnUrl: string; returnUrl: string;
}): Promise<void> { }): Promise<void> {
await NavigationStorage.savePendingChatImageReturn(input); await NavigationStorage.savePendingChatImageReturn(input);
} }
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> { export async function consumePendingChatImageReturn(
return NavigationStorage.consumePendingChatImageReturn(); characterId: string,
): Promise<PendingChatImageReturn | null> {
return NavigationStorage.consumePendingChatImageReturn(characterId);
} }
+17 -9
View File
@@ -18,6 +18,7 @@ export type {
}; };
export async function savePendingChatUnlock(input: { export async function savePendingChatUnlock(input: {
characterId: string;
displayMessageId?: string; displayMessageId?: string;
messageId?: string; messageId?: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
@@ -30,16 +31,20 @@ export async function savePendingChatUnlock(input: {
await NavigationStorage.savePendingChatUnlock(input); await NavigationStorage.savePendingChatUnlock(input);
} }
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> { export async function consumePendingChatUnlock(
return NavigationStorage.consumePendingChatUnlock(); characterId: string,
): Promise<PendingChatUnlock | null> {
return NavigationStorage.consumePendingChatUnlock(characterId);
} }
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> { export async function peekPendingChatUnlock(
return NavigationStorage.peekPendingChatUnlock(); characterId: string,
): Promise<PendingChatUnlock | null> {
return NavigationStorage.peekPendingChatUnlock(characterId);
} }
export async function hasPendingChatUnlock(): Promise<boolean> { export async function hasPendingChatUnlock(characterId: string): Promise<boolean> {
return NavigationStorage.hasPendingChatUnlock(); return NavigationStorage.hasPendingChatUnlock(characterId);
} }
export async function clearPendingChatUnlock(): Promise<void> { export async function clearPendingChatUnlock(): Promise<void> {
@@ -48,12 +53,15 @@ export async function clearPendingChatUnlock(): Promise<void> {
export async function savePendingChatPromotion( export async function savePendingChatPromotion(
promotionType: PendingChatPromotionType, promotionType: PendingChatPromotionType,
characterId: string,
): Promise<PendingChatPromotion> { ): Promise<PendingChatPromotion> {
return NavigationStorage.savePendingChatPromotion(promotionType); return NavigationStorage.savePendingChatPromotion(promotionType, characterId);
} }
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> { export async function consumePendingChatPromotion(
return NavigationStorage.consumePendingChatPromotion(); characterId: string,
): Promise<PendingChatPromotion | null> {
return NavigationStorage.consumePendingChatPromotion(characterId);
} }
export async function clearPendingChatPromotion(): Promise<void> { export async function clearPendingChatPromotion(): Promise<void> {
+23 -8
View File
@@ -1,6 +1,10 @@
"use client"; "use client";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import {
DEFAULT_CHARACTER_ID,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { getCharacterRoutes } from "@/router/routes"; import { getCharacterRoutes } from "@/router/routes";
import type { AppSubscriptionReturnTo } from "@/router/navigation-types"; import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
@@ -12,10 +16,13 @@ import {
export type SubscriptionReturnTo = AppSubscriptionReturnTo; export type SubscriptionReturnTo = AppSubscriptionReturnTo;
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> { export async function consumeSubscriptionExplicitExitUrl(
const pendingImageReturn = await consumePendingChatImageReturn(); characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string | null> {
const characterId = resolveCharacterId(characterSlug);
const pendingImageReturn = await consumePendingChatImageReturn(characterId);
if (pendingImageReturn) return pendingImageReturn.returnUrl; if (pendingImageReturn) return pendingImageReturn.returnUrl;
const pendingChatUnlock = await peekPendingChatUnlock(); const pendingChatUnlock = await peekPendingChatUnlock(characterId);
if (pendingChatUnlock) { if (pendingChatUnlock) {
await clearPendingChatUnlock(); await clearPendingChatUnlock();
return pendingChatUnlock.returnUrl; return pendingChatUnlock.returnUrl;
@@ -23,8 +30,12 @@ export async function consumeSubscriptionExplicitExitUrl(): Promise<string | nul
return null; return null;
} }
export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> { export async function peekSubscriptionExplicitExitUrl(
const pendingChatUnlock = await peekPendingChatUnlock(); characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string | null> {
const pendingChatUnlock = await peekPendingChatUnlock(
resolveCharacterId(characterSlug),
);
if (pendingChatUnlock) return pendingChatUnlock.returnUrl; if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
return null; return null;
} }
@@ -48,7 +59,7 @@ export async function consumeSubscriptionExitUrl(
} }
return ( return (
(await consumeSubscriptionExplicitExitUrl()) ?? (await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
getSubscriptionFallbackExitUrl(returnTo, characterSlug) getSubscriptionFallbackExitUrl(returnTo, characterSlug)
); );
} }
@@ -61,7 +72,11 @@ export async function resolveSubscriptionSuccessExitUrl(
return getCharacterRoutes(characterSlug).privateRoom; return getCharacterRoutes(characterSlug).privateRoom;
} }
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(); const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
if (pendingExitUrl) return pendingExitUrl; if (pendingExitUrl) return pendingExitUrl;
return consumeSubscriptionExitUrl(returnTo, characterSlug); return consumeSubscriptionExitUrl(returnTo, characterSlug);
} }
function resolveCharacterId(characterSlug: string): string {
return getCharacterBySlug(characterSlug)?.id ?? DEFAULT_CHARACTER_ID;
}
+4 -2
View File
@@ -153,6 +153,7 @@ export function useAppNavigator(): AppNavigator {
void (async () => { void (async () => {
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
characterId: character.id,
displayMessageId, displayMessageId,
messageId, messageId,
kind, kind,
@@ -165,7 +166,7 @@ export function useAppNavigator(): AppNavigator {
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl)); router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})(); })();
}, },
[isAuthenticatedUser, router], [character.id, isAuthenticatedUser, router],
); );
const openSubscriptionForPendingUnlock = useCallback( const openSubscriptionForPendingUnlock = useCallback(
@@ -183,6 +184,7 @@ export function useAppNavigator(): AppNavigator {
}: OpenSubscriptionForPendingUnlockInput): void => { }: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => { void (async () => {
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
characterId: character.id,
displayMessageId, displayMessageId,
messageId, messageId,
kind, kind,
@@ -200,7 +202,7 @@ export function useAppNavigator(): AppNavigator {
}); });
})(); })();
}, },
[getDefaultPayChannel, openSubscription], [character.id, getDefaultPayChannel, openSubscription],
); );
const exitSubscription = useCallback( const exitSubscription = useCallback(
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createActor } from "xstate";
const repository = vi.hoisted(() => ({
prefetchMediaForSendResponse: vi.fn(async () => ({
success: true as const,
data: undefined,
})),
sendMessage: vi.fn(),
unlockHistory: vi.fn(),
}));
const historySync = vi.hoisted(() => ({
readAndSyncHistory: vi.fn(),
readLocalHistorySnapshot: vi.fn(async () => ({
messages: [],
localCount: 0,
})),
resolveHistoryCacheIdentity: vi.fn(async () => null),
syncNetworkHistory: vi.fn(),
}));
vi.mock("@/data/repositories/chat_repository_loader", () => ({
loadChatRepository: vi.fn(async () => repository),
}));
vi.mock("@/data/repositories/chat_cache_identity", () => ({
resolveChatConversationKey: vi.fn(async () => ({
success: true,
data: "user:test::character:character_elio",
})),
}));
vi.mock("@/stores/chat/chat-history-sync", () => historySync);
import { loadHistoryActor } from "@/stores/chat/machine/actors/history";
import { sendMessageHttpActor } from "@/stores/chat/machine/actors/send";
import { unlockHistoryActor } from "@/stores/chat/machine/actors/unlock";
describe("chat actor request cancellation", () => {
beforeEach(() => {
repository.sendMessage.mockReset();
repository.unlockHistory.mockReset();
historySync.syncNetworkHistory.mockReset();
});
it("aborts an in-flight send request when its actor stops", async () => {
repository.sendMessage.mockImplementation(() => new Promise(() => undefined));
const actor = createActor(sendMessageHttpActor, {
input: { characterId: "character_elio", content: "hello" },
}).start();
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
const signal = getSignal(repository.sendMessage.mock.calls[0]?.[2]);
expect(signal.aborted).toBe(false);
actor.stop();
expect(signal.aborted).toBe(true);
});
it("aborts an in-flight history request when its callback actor stops", async () => {
historySync.syncNetworkHistory.mockImplementation(
() => new Promise(() => undefined),
);
const actor = createActor(loadHistoryActor, {
input: { characterId: "character_elio" },
}).start();
await vi.waitFor(() =>
expect(historySync.syncNetworkHistory).toHaveBeenCalled(),
);
const signal = getSignal(
historySync.syncNetworkHistory.mock.calls[0]?.[3],
);
expect(signal.aborted).toBe(false);
actor.stop();
expect(signal.aborted).toBe(true);
});
it("aborts an in-flight unlock request when its actor stops", async () => {
repository.unlockHistory.mockImplementation(
() => new Promise(() => undefined),
);
const actor = createActor(unlockHistoryActor, {
input: { characterId: "character_elio" },
}).start();
await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled());
const signal = getSignal(repository.unlockHistory.mock.calls[0]?.[1]);
expect(signal.aborted).toBe(false);
actor.stop();
expect(signal.aborted).toBe(true);
});
});
function getSignal(options: unknown): AbortSignal {
if (options instanceof AbortSignal) return options;
const signal = (options as { signal?: AbortSignal } | undefined)?.signal;
if (!signal) throw new Error("Missing AbortSignal");
return signal;
}
@@ -9,6 +9,7 @@ import {
} from "@/stores/chat/helper/promotion"; } from "@/stores/chat/helper/promotion";
const promotion: PendingChatPromotion = { const promotion: PendingChatPromotion = {
characterId: "character_elio",
promotionType: "image", promotionType: "image",
lockType: "image_paywall", lockType: "image_paywall",
clientLockId: "promotion-1", clientLockId: "promotion-1",
@@ -80,6 +80,7 @@ describe("chat session flow", () => {
actor.send({ actor.send({
type: "ChatPromotionInjected", type: "ChatPromotionInjected",
promotion: { promotion: {
characterId: "character_elio",
promotionType: "voice", promotionType: "voice",
lockType: "voice_message", lockType: "voice_message",
clientLockId: "promotion-1", clientLockId: "promotion-1",
+7
View File
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { todayString } from "@/utils/date"; import { todayString } from "@/utils/date";
import { isAbortError } from "@/utils/abort";
import { CHAT_HISTORY_LIMIT } from "./helper/history"; import { CHAT_HISTORY_LIMIT } from "./helper/history";
import { localMessagesToUi } from "./helper/message-mappers"; import { localMessagesToUi } from "./helper/message-mappers";
@@ -81,6 +82,7 @@ export async function syncNetworkHistory(
characterId: string, characterId: string,
localCount: number, localCount: number,
cacheIdentity: string | null, cacheIdentity: string | null,
signal?: AbortSignal,
): Promise<NetworkHistorySyncOutput | null> { ): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage(); const greetingMessage = createGreetingMessage();
@@ -89,13 +91,16 @@ export async function syncNetworkHistory(
characterId, characterId,
CHAT_HISTORY_LIMIT, CHAT_HISTORY_LIMIT,
0, 0,
{ signal },
); );
if (Result.isErr(networkResult)) { if (Result.isErr(networkResult)) {
if (isAbortError(networkResult.error)) throw networkResult.error;
log.error("[chat-machine] loadHistory NETWORK FAILED", { log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error, error: networkResult.error,
}); });
return null; return null;
} }
signal?.throwIfAborted();
const networkUi = localMessagesToUi(networkResult.data.messages); const networkUi = localMessagesToUi(networkResult.data.messages);
log.debug("[chat-machine] loadHistory NETWORK DONE", { log.debug("[chat-machine] loadHistory NETWORK DONE", {
@@ -144,6 +149,7 @@ export async function syncNetworkHistory(
*/ */
export async function readAndSyncHistory( export async function readAndSyncHistory(
characterId: string, characterId: string,
signal?: AbortSignal,
): Promise<ReadAndSyncHistoryOutput> { ): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId); const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
@@ -151,6 +157,7 @@ export async function readAndSyncHistory(
characterId, characterId,
localSnapshot.localCount, localSnapshot.localCount,
cacheIdentity, cacheIdentity,
signal,
); );
if (networkSnapshot) return networkSnapshot; if (networkSnapshot) return networkSnapshot;
+12 -2
View File
@@ -4,6 +4,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import { import {
readLocalHistorySnapshot, readLocalHistorySnapshot,
@@ -38,6 +39,7 @@ export const loadHistoryActor = fromCallback<
ChatHistoryActorInput ChatHistoryActorInput
>(({ input, sendBack }) => { >(({ input, sendBack }) => {
let cancelled = false; let cancelled = false;
const controller = new AbortController();
void (async () => { void (async () => {
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId); const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
@@ -52,6 +54,7 @@ export const loadHistoryActor = fromCallback<
input.characterId, input.characterId,
localSnapshot.localCount, localSnapshot.localCount,
cacheIdentity, cacheIdentity,
controller.signal,
); );
if (cancelled || !networkSnapshot) return; if (cancelled || !networkSnapshot) return;
sendBack({ sendBack({
@@ -59,13 +62,14 @@ export const loadHistoryActor = fromCallback<
output: networkSnapshot, output: networkSnapshot,
}); });
})().catch((error: unknown) => { })().catch((error: unknown) => {
if (cancelled) return; if (cancelled || isAbortError(error)) return;
log.error("[chat-machine] loadHistoryActor failed", { error }); log.error("[chat-machine] loadHistoryActor failed", { error });
sendBack({ type: "ChatHistoryLoadFailed", error }); sendBack({ type: "ChatHistoryLoadFailed", error });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort();
}; };
}); });
@@ -74,10 +78,13 @@ export const loadMoreHistoryActor =
({ input, receive, sendBack }) => { ({ input, receive, sendBack }) => {
let cancelled = false; let cancelled = false;
let running = false; let running = false;
let activeController: AbortController | null = null;
receive((event) => { receive((event) => {
if (running || cancelled) return; if (running || cancelled) return;
running = true; running = true;
const controller = new AbortController();
activeController = controller;
void (async () => { void (async () => {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
@@ -85,6 +92,7 @@ export const loadMoreHistoryActor =
input.characterId, input.characterId,
event.limit, event.limit,
event.offset, event.offset,
{ signal: controller.signal },
); );
if (Result.isErr(historyResult)) throw historyResult.error; if (Result.isErr(historyResult)) throw historyResult.error;
if (cancelled) return; if (cancelled) return;
@@ -111,17 +119,19 @@ export const loadMoreHistoryActor =
); );
})() })()
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled || isAbortError(error)) return;
log.warn("[chat-machine] loadMoreHistoryActor failed", { error }); log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
sendBack({ type: "ChatOlderHistoryLoadFailed", error }); sendBack({ type: "ChatOlderHistoryLoadFailed", error });
}) })
.finally(() => { .finally(() => {
if (activeController === controller) activeController = null;
running = false; running = false;
}); });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
activeController?.abort();
}; };
}, },
); );
+26 -6
View File
@@ -7,6 +7,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity"; import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import type { ChatEvent } from "../../chat-events"; import type { ChatEvent } from "../../chat-events";
import { sendResponseToUiMessage } from "../../helper/message-mappers"; import { sendResponseToUiMessage } from "../../helper/message-mappers";
@@ -18,8 +19,8 @@ type UiMessage = import("@/stores/chat/ui-message").UiMessage;
export const sendMessageHttpActor = fromPromise< export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null }, { response: ChatSendResponse; reply: UiMessage | null },
{ characterId: string; content: string } { characterId: string; content: string }
>(async ({ input }) => { >(async ({ input, signal }) => {
return sendMessageViaHttp(input.characterId, input.content); return sendMessageViaHttp(input.characterId, input.content, signal);
}); });
export const httpMessageQueueActor = fromCallback< export const httpMessageQueueActor = fromCallback<
@@ -37,13 +38,21 @@ function createMessageQueueActor(
receive: (listener: (event: ChatEvent) => void) => void, receive: (listener: (event: ChatEvent) => void) => void,
): () => void { ): () => void {
const queue = new MessageQueue(); const queue = new MessageQueue();
let activeController: AbortController | null = null;
queue.setConsumer(async (content) => { queue.setConsumer(async (content) => {
const controller = new AbortController();
activeController = controller;
sendBack({ type: "ChatQueuedSendStarted" }); sendBack({ type: "ChatQueuedSendStarted" });
try { try {
const output = await sendMessageViaHttp(characterId, content); const output = await sendMessageViaHttp(
characterId,
content,
controller.signal,
);
sendBack({ type: "ChatQueuedHttpDone", output }); sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) { } catch (error) {
if (isAbortError(error)) return;
const errorMessage = ExceptionHandler.message(error); const errorMessage = ExceptionHandler.message(error);
log.error("[chat-machine] message queue send failed", { log.error("[chat-machine] message queue send failed", {
error, error,
@@ -53,6 +62,8 @@ function createMessageQueueActor(
content, content,
errorMessage, errorMessage,
}); });
} finally {
if (activeController === controller) activeController = null;
} }
}); });
@@ -63,22 +74,31 @@ function createMessageQueueActor(
queue.enqueue(content); queue.enqueue(content);
}); });
return () => queue.dispose(); return () => {
activeController?.abort();
queue.dispose();
};
} }
async function sendMessageViaHttp(characterId: string, content: string): Promise<{ async function sendMessageViaHttp(
characterId: string,
content: string,
signal?: AbortSignal,
): Promise<{
response: ChatSendResponse; response: ChatSendResponse;
reply: UiMessage | null; reply: UiMessage | null;
}> { }> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const cacheIdentityResult = await resolveChatConversationKey(characterId); const cacheIdentityResult = await resolveChatConversationKey(characterId);
const result = await chatRepo.sendMessage(characterId, content); const result = await chatRepo.sendMessage(characterId, content, { signal });
if (Result.isErr(result)) { if (Result.isErr(result)) {
if (isAbortError(result.error)) throw result.error;
log.error("[chat-machine] sendMessageHttpActor failed", { log.error("[chat-machine] sendMessageHttpActor failed", {
error: result.error, error: result.error,
}); });
throw result.error; throw result.error;
} }
signal?.throwIfAborted();
if (Result.isOk(cacheIdentityResult)) { if (Result.isOk(cacheIdentityResult)) {
void chatRepo.prefetchMediaForSendResponse( void chatRepo.prefetchMediaForSendResponse(
result.data, result.data,
+20 -10
View File
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity"; import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import { readAndSyncHistory } from "../../chat-history-sync"; import { readAndSyncHistory } from "../../chat-history-sync";
import { import {
@@ -25,17 +26,21 @@ export interface UnlockHistoryOutput {
export const unlockHistoryActor = fromPromise< export const unlockHistoryActor = fromPromise<
UnlockHistoryOutput, UnlockHistoryOutput,
{ characterId: string } { characterId: string }
>(async ({ input }) => { >(async ({ input, signal }) => {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const unlockResult = await chatRepo.unlockHistory(input.characterId); const unlockResult = await chatRepo.unlockHistory(input.characterId, {
signal,
});
if (Result.isErr(unlockResult)) { if (Result.isErr(unlockResult)) {
if (isAbortError(unlockResult.error)) throw unlockResult.error;
log.error("[chat-machine] unlockHistoryActor failed", { log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error, error: unlockResult.error,
}); });
throw unlockResult.error; throw unlockResult.error;
} }
const history = await readAndSyncHistory(input.characterId); signal.throwIfAborted();
const history = await readAndSyncHistory(input.characterId, signal);
return { return {
unlocked: unlockResult.data.unlocked, unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason, reason: unlockResult.data.reason,
@@ -47,18 +52,22 @@ export const unlockHistoryActor = fromPromise<
export const unlockMessageActor = fromPromise< export const unlockMessageActor = fromPromise<
UnlockMessageOutput, UnlockMessageOutput,
UnlockMessageRequest & { characterId: string } UnlockMessageRequest & { characterId: string }
>(async ({ input }) => { >(async ({ input, signal }) => {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const cacheIdentityResult = await resolveChatConversationKey( const cacheIdentityResult = await resolveChatConversationKey(
input.characterId, input.characterId,
); );
const unlockResult = await chatRepo.unlockPrivateMessage({ const unlockResult = await chatRepo.unlockPrivateMessage(
characterId: input.characterId, {
...(input.messageId ? { messageId: input.messageId } : {}), characterId: input.characterId,
...(input.lockType ? { lockType: input.lockType } : {}), ...(input.messageId ? { messageId: input.messageId } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}), ...(input.lockType ? { lockType: input.lockType } : {}),
}); ...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
},
{ signal },
);
if (Result.isErr(unlockResult)) { if (Result.isErr(unlockResult)) {
if (isAbortError(unlockResult.error)) throw unlockResult.error;
log.error("[chat-machine] unlockMessageActor failed", { log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId, messageId: input.messageId,
clientLockId: input.clientLockId, clientLockId: input.clientLockId,
@@ -66,6 +75,7 @@ export const unlockMessageActor = fromPromise<
}); });
throw unlockResult.error; throw unlockResult.error;
} }
signal.throwIfAborted();
if ( if (
unlockResult.data.unlocked && unlockResult.data.unlocked &&
@@ -5,7 +5,10 @@ import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react"; import { shallowEqual } from "@xstate/react";
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
import { useChatDispatch } from "@/stores/chat/chat-context"; import {
useChatDispatch,
useChatSelector,
} from "@/stores/chat/chat-context";
import { usePaymentSelector } from "@/stores/payment/payment-context"; import { usePaymentSelector } from "@/stores/payment/payment-context";
export function ChatPaymentSuccessSync() { export function ChatPaymentSuccessSync() {
@@ -17,6 +20,7 @@ export function ChatPaymentSuccessSync() {
}), }),
shallowEqual, shallowEqual,
); );
const characterId = useChatSelector((state) => state.context.characterId);
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null); const lastPaidKeyRef = useRef<string | null>(null);
@@ -29,7 +33,7 @@ export function ChatPaymentSuccessSync() {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
if (await hasPendingChatUnlock()) return; if (await hasPendingChatUnlock(characterId)) return;
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
})(); })();
@@ -38,6 +42,7 @@ export function ChatPaymentSuccessSync() {
}; };
}, [ }, [
chatDispatch, chatDispatch,
characterId,
paymentState.currentOrderId, paymentState.currentOrderId,
paymentState.isPaid, paymentState.isPaid,
paymentState.orderStatus, paymentState.orderStatus,
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isAbortError } from "@/utils/abort";
import { Result } from "@/utils/result";
describe("isAbortError", () => {
it("recognizes cancellation after Result normalizes the error", async () => {
const result = await Result.wrap(async () => {
throw new DOMException("This operation was aborted", "AbortError");
});
expect(Result.isErr(result) && isAbortError(result.error)).toBe(true);
});
it("does not treat request timeouts as actor cancellation", () => {
expect(isAbortError(new DOMException("Timed out", "TimeoutError"))).toBe(
false,
);
});
});
+6
View File
@@ -0,0 +1,6 @@
export function isAbortError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === "AbortError") return true;
const cause = error.cause;
return cause !== error && isAbortError(cause);
}