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 = {
characterId: "character_elio",
promotionType: "image",
lockType: "image_paywall",
clientLockId: "promotion-1",
@@ -181,6 +182,7 @@ function createPendingUnlock(
): PendingChatUnlock {
return {
reason: "single_message_unlock",
characterId: "character_elio",
...input,
stage: "auth",
createdAt: 1,
+3 -1
View File
@@ -58,7 +58,9 @@ export function ChatScreen() {
loginStatus: authState.loginStatus,
messages: state.historyMessages,
});
const isPromotionBootstrapReady = useChatPromotionBootstrap();
const isPromotionBootstrapReady = useChatPromotionBootstrap(
state.characterId,
);
const visibleMessages = isPromotionBootstrapReady
? state.messages
: state.historyMessages;
@@ -11,7 +11,7 @@ import { Logger } from "@/utils/logger";
const log = new Logger("UseChatPromotionBootstrap");
export function useChatPromotionBootstrap(): boolean {
export function useChatPromotionBootstrap(characterId: string): boolean {
const chatDispatch = useChatDispatch();
const startedRef = useRef(false);
const [isReady, setIsReady] = useState(false);
@@ -23,8 +23,8 @@ export function useChatPromotionBootstrap(): boolean {
void (async () => {
try {
const [entryPromotion, pendingUnlock] = await Promise.all([
consumePendingChatPromotion(),
peekPendingChatUnlock(),
consumePendingChatPromotion(characterId),
peekPendingChatUnlock(characterId),
]);
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
@@ -46,7 +46,7 @@ export function useChatPromotionBootstrap(): boolean {
setIsReady(true);
}
})();
}, [chatDispatch]);
}, [characterId, chatDispatch]);
return isReady;
}
@@ -79,6 +79,7 @@ export function useChatUnlockCoordinator({
const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector(
(state) => ({
characterId: state.context.characterId,
historyLoaded: state.context.historyLoaded,
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount,
@@ -125,7 +126,7 @@ export function useChatUnlockCoordinator({
let cancelled = false;
const resumePendingUnlock = async () => {
const pending = await peekPendingChatUnlock();
const pending = await peekPendingChatUnlock(chatState.characterId);
if (
cancelled ||
!pending ||
@@ -138,7 +139,7 @@ export function useChatUnlockCoordinator({
return;
}
const consumed = await consumePendingChatUnlock();
const consumed = await consumePendingChatUnlock(chatState.characterId);
if (cancelled || !consumed) return;
chatDispatch({
@@ -157,6 +158,7 @@ export function useChatUnlockCoordinator({
};
}, [
chatDispatch,
chatState.characterId,
chatState.historyLoaded,
defaultReturnUrl,
enabled,
@@ -15,6 +15,10 @@ import {
savePendingChatPromotion,
} from "@/lib/navigation/chat_unlock_session";
import { ROUTES } from "@/router/routes";
import {
DEFAULT_CHARACTER_ID,
getCharacterBySlug,
} from "@/data/constants/character";
import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
@@ -49,6 +53,8 @@ export default function ExternalEntryPersist({
const hasReinitializedForPsidRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target });
const destination = resolveExternalEntryDestination({ target, character });
const characterId =
getCharacterBySlug(character)?.id ?? DEFAULT_CHARACTER_ID;
const resolvedPromotionType = resolveExternalEntryPromotionType({
mode,
promotionType,
@@ -66,7 +72,7 @@ export default function ExternalEntryPersist({
avatarUrl,
}),
targetRoute === ROUTES.chat && resolvedPromotionType
? savePendingChatPromotion(resolvedPromotionType)
? savePendingChatPromotion(resolvedPromotionType, characterId)
: clearPendingChatPromotion(),
]);
for (const result of results) {
@@ -87,6 +93,7 @@ export default function ExternalEntryPersist({
avatarUrl,
asid,
character,
characterId,
destination,
deviceId,
mode,
@@ -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,
),
);
}
}
+9 -4
View File
@@ -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
+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, {
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);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatUnlock(result.data);
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);
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);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatPromotion(result.data);
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);
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);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatImageReturn(result.data);
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);
return value;
}
private static parsePendingChatUnlock(
@@ -39,6 +39,7 @@ describe("subscription exit helpers", () => {
it("uses explicit chat image return urls first", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall",
characterId: "character_elio",
messageId: "msg_1",
returnUrl: "/chat?image=msg_1",
createdAt: 1,
@@ -73,12 +74,14 @@ describe("subscription exit helpers", () => {
it("returns directly to private room without consuming chat state", async () => {
consumePendingChatImageReturnMock.mockResolvedValue({
reason: "image_paywall",
characterId: "character_elio",
messageId: "msg_1",
returnUrl: "/chat?image=msg_1",
createdAt: 1,
});
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
@@ -101,6 +104,7 @@ describe("subscription exit helpers", () => {
it("keeps private room ahead of stale chat state after payment", async () => {
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
@@ -119,6 +123,7 @@ describe("subscription exit helpers", () => {
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
@@ -136,6 +141,7 @@ describe("subscription exit helpers", () => {
it("peeks chat unlock return urls without clearing them", async () => {
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
@@ -154,6 +160,7 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
@@ -8,12 +8,15 @@ import {
export type { PendingChatImageReturn };
export async function savePendingChatImageReturn(input: {
characterId: string;
messageId: string;
returnUrl: string;
}): Promise<void> {
await NavigationStorage.savePendingChatImageReturn(input);
}
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
return NavigationStorage.consumePendingChatImageReturn();
export async function 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: {
characterId: string;
displayMessageId?: string;
messageId?: string;
kind: PendingChatUnlockKind;
@@ -30,16 +31,20 @@ export async function savePendingChatUnlock(input: {
await NavigationStorage.savePendingChatUnlock(input);
}
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
return NavigationStorage.consumePendingChatUnlock();
export async function consumePendingChatUnlock(
characterId: string,
): Promise<PendingChatUnlock | null> {
return NavigationStorage.consumePendingChatUnlock(characterId);
}
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
return NavigationStorage.peekPendingChatUnlock();
export async function peekPendingChatUnlock(
characterId: string,
): Promise<PendingChatUnlock | null> {
return NavigationStorage.peekPendingChatUnlock(characterId);
}
export async function hasPendingChatUnlock(): Promise<boolean> {
return NavigationStorage.hasPendingChatUnlock();
export async function hasPendingChatUnlock(characterId: string): Promise<boolean> {
return NavigationStorage.hasPendingChatUnlock(characterId);
}
export async function clearPendingChatUnlock(): Promise<void> {
@@ -48,12 +53,15 @@ export async function clearPendingChatUnlock(): Promise<void> {
export async function savePendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): Promise<PendingChatPromotion> {
return NavigationStorage.savePendingChatPromotion(promotionType);
return NavigationStorage.savePendingChatPromotion(promotionType, characterId);
}
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
return NavigationStorage.consumePendingChatPromotion();
export async function consumePendingChatPromotion(
characterId: string,
): Promise<PendingChatPromotion | null> {
return NavigationStorage.consumePendingChatPromotion(characterId);
}
export async function clearPendingChatPromotion(): Promise<void> {
+23 -8
View File
@@ -1,6 +1,10 @@
"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 type { AppSubscriptionReturnTo } from "@/router/navigation-types";
@@ -12,10 +16,13 @@ import {
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingImageReturn = await consumePendingChatImageReturn();
export async function consumeSubscriptionExplicitExitUrl(
characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string | null> {
const characterId = resolveCharacterId(characterSlug);
const pendingImageReturn = await consumePendingChatImageReturn(characterId);
if (pendingImageReturn) return pendingImageReturn.returnUrl;
const pendingChatUnlock = await peekPendingChatUnlock();
const pendingChatUnlock = await peekPendingChatUnlock(characterId);
if (pendingChatUnlock) {
await clearPendingChatUnlock();
return pendingChatUnlock.returnUrl;
@@ -23,8 +30,12 @@ export async function consumeSubscriptionExplicitExitUrl(): Promise<string | nul
return null;
}
export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> {
const pendingChatUnlock = await peekPendingChatUnlock();
export async function peekSubscriptionExplicitExitUrl(
characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string | null> {
const pendingChatUnlock = await peekPendingChatUnlock(
resolveCharacterId(characterSlug),
);
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
return null;
}
@@ -48,7 +59,7 @@ export async function consumeSubscriptionExitUrl(
}
return (
(await consumeSubscriptionExplicitExitUrl()) ??
(await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
);
}
@@ -61,7 +72,11 @@ export async function resolveSubscriptionSuccessExitUrl(
return getCharacterRoutes(characterSlug).privateRoom;
}
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
if (pendingExitUrl) return pendingExitUrl;
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 () => {
await NavigationStorage.savePendingChatUnlock({
characterId: character.id,
displayMessageId,
messageId,
kind,
@@ -165,7 +166,7 @@ export function useAppNavigator(): AppNavigator {
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
},
[isAuthenticatedUser, router],
[character.id, isAuthenticatedUser, router],
);
const openSubscriptionForPendingUnlock = useCallback(
@@ -183,6 +184,7 @@ export function useAppNavigator(): AppNavigator {
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
characterId: character.id,
displayMessageId,
messageId,
kind,
@@ -200,7 +202,7 @@ export function useAppNavigator(): AppNavigator {
});
})();
},
[getDefaultPayChannel, openSubscription],
[character.id, getDefaultPayChannel, openSubscription],
);
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";
const promotion: PendingChatPromotion = {
characterId: "character_elio",
promotionType: "image",
lockType: "image_paywall",
clientLockId: "promotion-1",
@@ -80,6 +80,7 @@ describe("chat session flow", () => {
actor.send({
type: "ChatPromotionInjected",
promotion: {
characterId: "character_elio",
promotionType: "voice",
lockType: "voice_message",
clientLockId: "promotion-1",
+7
View File
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { todayString } from "@/utils/date";
import { isAbortError } from "@/utils/abort";
import { CHAT_HISTORY_LIMIT } from "./helper/history";
import { localMessagesToUi } from "./helper/message-mappers";
@@ -81,6 +82,7 @@ export async function syncNetworkHistory(
characterId: string,
localCount: number,
cacheIdentity: string | null,
signal?: AbortSignal,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage();
@@ -89,13 +91,16 @@ export async function syncNetworkHistory(
characterId,
CHAT_HISTORY_LIMIT,
0,
{ signal },
);
if (Result.isErr(networkResult)) {
if (isAbortError(networkResult.error)) throw networkResult.error;
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
});
return null;
}
signal?.throwIfAborted();
const networkUi = localMessagesToUi(networkResult.data.messages);
log.debug("[chat-machine] loadHistory NETWORK DONE", {
@@ -144,6 +149,7 @@ export async function syncNetworkHistory(
*/
export async function readAndSyncHistory(
characterId: string,
signal?: AbortSignal,
): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
@@ -151,6 +157,7 @@ export async function readAndSyncHistory(
characterId,
localSnapshot.localCount,
cacheIdentity,
signal,
);
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 { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import {
readLocalHistorySnapshot,
@@ -38,6 +39,7 @@ export const loadHistoryActor = fromCallback<
ChatHistoryActorInput
>(({ input, sendBack }) => {
let cancelled = false;
const controller = new AbortController();
void (async () => {
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
@@ -52,6 +54,7 @@ export const loadHistoryActor = fromCallback<
input.characterId,
localSnapshot.localCount,
cacheIdentity,
controller.signal,
);
if (cancelled || !networkSnapshot) return;
sendBack({
@@ -59,13 +62,14 @@ export const loadHistoryActor = fromCallback<
output: networkSnapshot,
});
})().catch((error: unknown) => {
if (cancelled) return;
if (cancelled || isAbortError(error)) return;
log.error("[chat-machine] loadHistoryActor failed", { error });
sendBack({ type: "ChatHistoryLoadFailed", error });
});
return () => {
cancelled = true;
controller.abort();
};
});
@@ -74,10 +78,13 @@ export const loadMoreHistoryActor =
({ input, receive, sendBack }) => {
let cancelled = false;
let running = false;
let activeController: AbortController | null = null;
receive((event) => {
if (running || cancelled) return;
running = true;
const controller = new AbortController();
activeController = controller;
void (async () => {
const chatRepo = await loadChatRepository();
@@ -85,6 +92,7 @@ export const loadMoreHistoryActor =
input.characterId,
event.limit,
event.offset,
{ signal: controller.signal },
);
if (Result.isErr(historyResult)) throw historyResult.error;
if (cancelled) return;
@@ -111,17 +119,19 @@ export const loadMoreHistoryActor =
);
})()
.catch((error: unknown) => {
if (cancelled) return;
if (cancelled || isAbortError(error)) return;
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
})
.finally(() => {
if (activeController === controller) activeController = null;
running = false;
});
});
return () => {
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 { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import type { ChatEvent } from "../../chat-events";
import { sendResponseToUiMessage } from "../../helper/message-mappers";
@@ -18,8 +19,8 @@ type UiMessage = import("@/stores/chat/ui-message").UiMessage;
export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null },
{ characterId: string; content: string }
>(async ({ input }) => {
return sendMessageViaHttp(input.characterId, input.content);
>(async ({ input, signal }) => {
return sendMessageViaHttp(input.characterId, input.content, signal);
});
export const httpMessageQueueActor = fromCallback<
@@ -37,13 +38,21 @@ function createMessageQueueActor(
receive: (listener: (event: ChatEvent) => void) => void,
): () => void {
const queue = new MessageQueue();
let activeController: AbortController | null = null;
queue.setConsumer(async (content) => {
const controller = new AbortController();
activeController = controller;
sendBack({ type: "ChatQueuedSendStarted" });
try {
const output = await sendMessageViaHttp(characterId, content);
const output = await sendMessageViaHttp(
characterId,
content,
controller.signal,
);
sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) {
if (isAbortError(error)) return;
const errorMessage = ExceptionHandler.message(error);
log.error("[chat-machine] message queue send failed", {
error,
@@ -53,6 +62,8 @@ function createMessageQueueActor(
content,
errorMessage,
});
} finally {
if (activeController === controller) activeController = null;
}
});
@@ -63,22 +74,31 @@ function createMessageQueueActor(
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;
reply: UiMessage | null;
}> {
const chatRepo = await loadChatRepository();
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 (isAbortError(result.error)) throw result.error;
log.error("[chat-machine] sendMessageHttpActor failed", {
error: result.error,
});
throw result.error;
}
signal?.throwIfAborted();
if (Result.isOk(cacheIdentityResult)) {
void chatRepo.prefetchMediaForSendResponse(
result.data,
+16 -6
View File
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { isAbortError } from "@/utils/abort";
import { readAndSyncHistory } from "../../chat-history-sync";
import {
@@ -25,17 +26,21 @@ export interface UnlockHistoryOutput {
export const unlockHistoryActor = fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async ({ input }) => {
>(async ({ input, signal }) => {
const chatRepo = await loadChatRepository();
const unlockResult = await chatRepo.unlockHistory(input.characterId);
const unlockResult = await chatRepo.unlockHistory(input.characterId, {
signal,
});
if (Result.isErr(unlockResult)) {
if (isAbortError(unlockResult.error)) throw unlockResult.error;
log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error,
});
throw unlockResult.error;
}
const history = await readAndSyncHistory(input.characterId);
signal.throwIfAborted();
const history = await readAndSyncHistory(input.characterId, signal);
return {
unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason,
@@ -47,18 +52,22 @@ export const unlockHistoryActor = fromPromise<
export const unlockMessageActor = fromPromise<
UnlockMessageOutput,
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
>(async ({ input, signal }) => {
const chatRepo = await loadChatRepository();
const cacheIdentityResult = await resolveChatConversationKey(
input.characterId,
);
const unlockResult = await chatRepo.unlockPrivateMessage({
const unlockResult = await chatRepo.unlockPrivateMessage(
{
characterId: input.characterId,
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.lockType ? { lockType: input.lockType } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
});
},
{ signal },
);
if (Result.isErr(unlockResult)) {
if (isAbortError(unlockResult.error)) throw unlockResult.error;
log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId,
clientLockId: input.clientLockId,
@@ -66,6 +75,7 @@ export const unlockMessageActor = fromPromise<
});
throw unlockResult.error;
}
signal.throwIfAborted();
if (
unlockResult.data.unlocked &&
@@ -5,7 +5,10 @@ import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
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";
export function ChatPaymentSuccessSync() {
@@ -17,6 +20,7 @@ export function ChatPaymentSuccessSync() {
}),
shallowEqual,
);
const characterId = useChatSelector((state) => state.context.characterId);
const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null);
@@ -29,7 +33,7 @@ export function ChatPaymentSuccessSync() {
let cancelled = false;
void (async () => {
if (await hasPendingChatUnlock()) return;
if (await hasPendingChatUnlock(characterId)) return;
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
})();
@@ -38,6 +42,7 @@ export function ChatPaymentSuccessSync() {
};
}, [
chatDispatch,
characterId,
paymentState.currentOrderId,
paymentState.isPaid,
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);
}