diff --git a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts index 37f27a01..11cbbe1c 100644 --- a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts +++ b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts @@ -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, diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index dd3e8356..83124f88 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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; diff --git a/src/app/chat/hooks/use-chat-promotion-bootstrap.ts b/src/app/chat/hooks/use-chat-promotion-bootstrap.ts index 39dc57d4..600f435f 100644 --- a/src/app/chat/hooks/use-chat-promotion-bootstrap.ts +++ b/src/app/chat/hooks/use-chat-promotion-bootstrap.ts @@ -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; } diff --git a/src/app/chat/hooks/use-chat-unlock-coordinator.ts b/src/app/chat/hooks/use-chat-unlock-coordinator.ts index 0f4cf015..85a15a3f 100644 --- a/src/app/chat/hooks/use-chat-unlock-coordinator.ts +++ b/src/app/chat/hooks/use-chat-unlock-coordinator.ts @@ -79,6 +79,7 @@ export function useChatUnlockCoordinator({ const trackedPaywallRef = useRef(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, diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index e7150e1e..2df36917 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -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, diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts index c771f632..181ac5e8 100644 --- a/src/data/repositories/chat_remote_data_source.ts +++ b/src/data/repositories/chat_remote_data_source.ts @@ -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> { 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> { - 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> { return Result.wrap(() => - this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)), + this.api.unlockPrivateMessage( + UnlockPrivateRequestSchema.parse(input), + options, + ), ); } async unlockHistory( characterId: string, + options?: ChatRequestOptions, ): Promise> { return Result.wrap(() => - this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })), + this.api.unlockHistory( + UnlockHistoryRequestSchema.parse({ characterId }), + options, + ), ); } } diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 4f0645ca..e57240ba 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -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> { 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> { - return this.remote.getHistory(characterId, limit, offset); + return this.remote.getHistory(characterId, limit, offset, options); } /** 解锁单条历史付费 / 私密消息。 */ async unlockPrivateMessage( input: UnlockPrivateMessageInput, + options?: ChatRequestOptions, ): Promise> { - return this.remote.unlockPrivateMessage(input); + return this.remote.unlockPrivateMessage(input, options); } /** 一键解锁历史锁定消息。 */ async unlockHistory( characterId: string, + options?: ChatRequestOptions, ): Promise> { - return this.remote.unlockHistory(characterId); + return this.remote.unlockHistory(characterId, options); } /** 把本地缓存中的单条锁定消息标记为已解锁。 */ diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 07d52ac8..4f0f205b 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -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>; /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ @@ -55,15 +64,20 @@ export interface IChatRepository { characterId: string, limit?: number, offset?: number, + options?: ChatRequestOptions, ): Promise>; /** 解锁单条历史付费 / 私密消息。 */ unlockPrivateMessage( input: UnlockPrivateMessageInput, + options?: ChatRequestOptions, ): Promise>; /** 一键解锁历史锁定消息。 */ - unlockHistory(characterId: string): Promise>; + unlockHistory( + characterId: string, + options?: ChatRequestOptions, + ): Promise>; /** 把本地缓存中的单条锁定消息标记为已解锁。 */ markPrivateMessageUnlockedInLocal( diff --git a/src/data/services/api/__tests__/multi_character_api.test.ts b/src/data/services/api/__tests__/multi_character_api.test.ts index 90a42dd7..9f2b8d42 100644 --- a/src/data/services/api/__tests__/multi_character_api.test.ts +++ b/src/data/services/api/__tests__/multi_character_api.test.ts @@ -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 diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index 8f4d5dd9..657f2a30 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -25,10 +25,14 @@ export class ChatApi { /** * 发送消息 */ - async sendMessage(body: SendMessageRequest): Promise { + async sendMessage( + body: SendMessageRequest, + options?: { signal?: AbortSignal }, + ): Promise { const env = await httpClient>(ApiPath.chatSend, { method: "POST", body, + ...(options?.signal ? { signal: options.signal } : {}), }); return ChatSendResponseSchema.parse(unwrap(env) as Record); } @@ -40,9 +44,11 @@ export class ChatApi { characterId: string, limit = 50, offset = 0, + options?: { signal?: AbortSignal }, ): Promise { const env = await httpClient>(ApiPath.chatHistory, { query: { characterId, limit, offset }, + ...(options?.signal ? { signal: options.signal } : {}), }); return ChatHistoryResponseSchema.parse( unwrap(env) as Record, @@ -54,12 +60,14 @@ export class ChatApi { */ async unlockPrivateMessage( body: UnlockPrivateRequest, + options?: { signal?: AbortSignal }, ): Promise { const env = await httpClient>( 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 { const env = await httpClient>( ApiPath.chatUnlockHistory, { method: "POST", body, + ...(options?.signal ? { signal: options.signal } : {}), }, ); return UnlockHistoryResponseSchema.parse( diff --git a/src/data/storage/navigation/__tests__/navigation_storage.test.ts b/src/data/storage/navigation/__tests__/navigation_storage.test.ts index 1cee66ea..2aab3fe4 100644 --- a/src/data/storage/navigation/__tests__/navigation_storage.test.ts +++ b/src/data/storage/navigation/__tests__/navigation_storage.test.ts @@ -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 }); + }); }); diff --git a/src/data/storage/navigation/navigation_storage.ts b/src/data/storage/navigation/navigation_storage.ts index 499ccd58..3cd72c20 100644 --- a/src/data/storage/navigation/navigation_storage.ts +++ b/src/data/storage/navigation/navigation_storage.ts @@ -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 { + static async consumePendingChatUnlock( + characterId: string, + ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatUnlock, PendingChatUnlockSchema, ); + if (Result.isErr(result)) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); + return null; + } + const value = NavigationStorage.parsePendingChatUnlock(result.data); + if (!value) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); + return null; + } + if (value.characterId !== characterId) return null; await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); - if (Result.isErr(result)) return null; - return NavigationStorage.parsePendingChatUnlock(result.data); + return value; } - static async peekPendingChatUnlock(): Promise { + static async peekPendingChatUnlock( + characterId: string, + ): Promise { 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 { - return (await NavigationStorage.peekPendingChatUnlock()) !== null; + static async hasPendingChatUnlock(characterId: string): Promise { + return ( + (await NavigationStorage.peekPendingChatUnlock(characterId)) !== null + ); } static async clearPendingChatUnlock(): Promise { @@ -143,8 +172,10 @@ export class NavigationStorage { static async savePendingChatPromotion( promotionType: PendingChatPromotionType, + characterId: string, ): Promise { const promotion: PendingChatPromotion = { + characterId, promotionType, lockType: toPromotionLockType(promotionType), clientLockId: `promotion_${createUuidV4()}`, @@ -158,14 +189,25 @@ export class NavigationStorage { return promotion; } - static async consumePendingChatPromotion(): Promise { + static async consumePendingChatPromotion( + characterId: string, + ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatPromotion, PendingChatPromotionSchema, ); + if (Result.isErr(result)) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); + return null; + } + const value = NavigationStorage.parsePendingChatPromotion(result.data); + if (!value) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); + return null; + } + if (value.characterId !== characterId) return null; await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); - if (Result.isErr(result)) return null; - return NavigationStorage.parsePendingChatPromotion(result.data); + return value; } static async clearPendingChatPromotion(): Promise { @@ -173,11 +215,13 @@ export class NavigationStorage { } static async savePendingChatImageReturn(input: { + characterId: string; messageId: string; returnUrl: string; }): Promise { 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 { + static async consumePendingChatImageReturn( + characterId: string, + ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatImageReturn, PendingChatImageReturnSchema, ); + if (Result.isErr(result)) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); + return null; + } + const value = NavigationStorage.parsePendingChatImageReturn(result.data); + if (!value) { + await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); + return null; + } + if (value.characterId !== characterId) return null; await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); - if (Result.isErr(result)) return null; - return NavigationStorage.parsePendingChatImageReturn(result.data); + return value; } private static parsePendingChatUnlock( diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts index 5344b433..bd7ed528 100644 --- a/src/lib/navigation/__tests__/subscription_exit.test.ts +++ b/src/lib/navigation/__tests__/subscription_exit.test.ts @@ -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", diff --git a/src/lib/navigation/chat_image_return_session.ts b/src/lib/navigation/chat_image_return_session.ts index 8abc19d9..544098e1 100644 --- a/src/lib/navigation/chat_image_return_session.ts +++ b/src/lib/navigation/chat_image_return_session.ts @@ -8,12 +8,15 @@ import { export type { PendingChatImageReturn }; export async function savePendingChatImageReturn(input: { + characterId: string; messageId: string; returnUrl: string; }): Promise { await NavigationStorage.savePendingChatImageReturn(input); } -export async function consumePendingChatImageReturn(): Promise { - return NavigationStorage.consumePendingChatImageReturn(); +export async function consumePendingChatImageReturn( + characterId: string, +): Promise { + return NavigationStorage.consumePendingChatImageReturn(characterId); } diff --git a/src/lib/navigation/chat_unlock_session.ts b/src/lib/navigation/chat_unlock_session.ts index 7f70ba64..ac3393fb 100644 --- a/src/lib/navigation/chat_unlock_session.ts +++ b/src/lib/navigation/chat_unlock_session.ts @@ -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 { - return NavigationStorage.consumePendingChatUnlock(); +export async function consumePendingChatUnlock( + characterId: string, +): Promise { + return NavigationStorage.consumePendingChatUnlock(characterId); } -export async function peekPendingChatUnlock(): Promise { - return NavigationStorage.peekPendingChatUnlock(); +export async function peekPendingChatUnlock( + characterId: string, +): Promise { + return NavigationStorage.peekPendingChatUnlock(characterId); } -export async function hasPendingChatUnlock(): Promise { - return NavigationStorage.hasPendingChatUnlock(); +export async function hasPendingChatUnlock(characterId: string): Promise { + return NavigationStorage.hasPendingChatUnlock(characterId); } export async function clearPendingChatUnlock(): Promise { @@ -48,12 +53,15 @@ export async function clearPendingChatUnlock(): Promise { export async function savePendingChatPromotion( promotionType: PendingChatPromotionType, + characterId: string, ): Promise { - return NavigationStorage.savePendingChatPromotion(promotionType); + return NavigationStorage.savePendingChatPromotion(promotionType, characterId); } -export async function consumePendingChatPromotion(): Promise { - return NavigationStorage.consumePendingChatPromotion(); +export async function consumePendingChatPromotion( + characterId: string, +): Promise { + return NavigationStorage.consumePendingChatPromotion(characterId); } export async function clearPendingChatPromotion(): Promise { diff --git a/src/lib/navigation/subscription_exit.ts b/src/lib/navigation/subscription_exit.ts index a317d347..c2dd18d9 100644 --- a/src/lib/navigation/subscription_exit.ts +++ b/src/lib/navigation/subscription_exit.ts @@ -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 { - const pendingImageReturn = await consumePendingChatImageReturn(); +export async function consumeSubscriptionExplicitExitUrl( + characterSlug: string = DEFAULT_CHARACTER_SLUG, +): Promise { + 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 { - const pendingChatUnlock = await peekPendingChatUnlock(); +export async function peekSubscriptionExplicitExitUrl( + characterSlug: string = DEFAULT_CHARACTER_SLUG, +): Promise { + 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; +} diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts index 0f444831..a918b1c3 100644 --- a/src/router/use-app-navigator.ts +++ b/src/router/use-app-navigator.ts @@ -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( diff --git a/src/stores/chat/__tests__/chat-actor-cancellation.test.ts b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts new file mode 100644 index 00000000..22003c3c --- /dev/null +++ b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts @@ -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; +} diff --git a/src/stores/chat/__tests__/chat-promotion.test.ts b/src/stores/chat/__tests__/chat-promotion.test.ts index dfb850a7..cf287f77 100644 --- a/src/stores/chat/__tests__/chat-promotion.test.ts +++ b/src/stores/chat/__tests__/chat-promotion.test.ts @@ -9,6 +9,7 @@ import { } from "@/stores/chat/helper/promotion"; const promotion: PendingChatPromotion = { + characterId: "character_elio", promotionType: "image", lockType: "image_paywall", clientLockId: "promotion-1", diff --git a/src/stores/chat/__tests__/chat-session-flow.test.ts b/src/stores/chat/__tests__/chat-session-flow.test.ts index 2b2a4a34..dd44aa30 100644 --- a/src/stores/chat/__tests__/chat-session-flow.test.ts +++ b/src/stores/chat/__tests__/chat-session-flow.test.ts @@ -80,6 +80,7 @@ describe("chat session flow", () => { actor.send({ type: "ChatPromotionInjected", promotion: { + characterId: "character_elio", promotionType: "voice", lockType: "voice_message", clientLockId: "promotion-1", diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 013ee11b..2f2b1f5e 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -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 { 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 { 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; diff --git a/src/stores/chat/machine/actors/history.ts b/src/stores/chat/machine/actors/history.ts index 4207e90d..ae47815d 100644 --- a/src/stores/chat/machine/actors/history.ts +++ b/src/stores/chat/machine/actors/history.ts @@ -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(); }; }, ); diff --git a/src/stores/chat/machine/actors/send.ts b/src/stores/chat/machine/actors/send.ts index 4b0730b4..9c45d110 100644 --- a/src/stores/chat/machine/actors/send.ts +++ b/src/stores/chat/machine/actors/send.ts @@ -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, diff --git a/src/stores/chat/machine/actors/unlock.ts b/src/stores/chat/machine/actors/unlock.ts index 087e2b18..0463d959 100644 --- a/src/stores/chat/machine/actors/unlock.ts +++ b/src/stores/chat/machine/actors/unlock.ts @@ -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({ - characterId: input.characterId, - ...(input.messageId ? { messageId: input.messageId } : {}), - ...(input.lockType ? { lockType: input.lockType } : {}), - ...(input.clientLockId ? { clientLockId: input.clientLockId } : {}), - }); + 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 && diff --git a/src/stores/sync/chat-payment-success-sync.tsx b/src/stores/sync/chat-payment-success-sync.tsx index 6dfc913c..4adeace3 100644 --- a/src/stores/sync/chat-payment-success-sync.tsx +++ b/src/stores/sync/chat-payment-success-sync.tsx @@ -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(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, diff --git a/src/utils/__tests__/abort.test.ts b/src/utils/__tests__/abort.test.ts new file mode 100644 index 00000000..81c6b28c --- /dev/null +++ b/src/utils/__tests__/abort.test.ts @@ -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, + ); + }); +}); diff --git a/src/utils/abort.ts b/src/utils/abort.ts new file mode 100644 index 00000000..515f3775 --- /dev/null +++ b/src/utils/abort.ts @@ -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); +}