diff --git a/src/app/chat/hooks/use-chat-unlock-coordinator.ts b/src/app/chat/hooks/use-chat-unlock-coordinator.ts index 756d3b59..0f4cf015 100644 --- a/src/app/chat/hooks/use-chat-unlock-coordinator.ts +++ b/src/app/chat/hooks/use-chat-unlock-coordinator.ts @@ -80,7 +80,7 @@ export function useChatUnlockCoordinator({ const chatState = useChatSelector( (state) => ({ historyLoaded: state.context.historyLoaded, - isUnlockingHistory: state.context.isUnlockingHistory, + isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), lockedHistoryCount: state.context.lockedHistoryCount, unlockHistoryError: state.context.unlockHistoryError, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index ae40fc79..3bd8bf9a 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -49,14 +49,8 @@ function makeChatState(overrides: Partial = {}): ChatState { paymentUnlockPending: false, unlockHistoryPromptVisible: false, lockedHistoryCount: 0, - isUnlockingHistory: false, unlockHistoryError: null, - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, + unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, ...overrides, diff --git a/src/stores/chat/__tests__/chat-unlock-flow.test.ts b/src/stores/chat/__tests__/chat-unlock-flow.test.ts index bec9de4b..cd406099 100644 --- a/src/stores/chat/__tests__/chat-unlock-flow.test.ts +++ b/src/stores/chat/__tests__/chat-unlock-flow.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; -import { createActor, waitFor } from "xstate"; +import { createActor, fromPromise, waitFor } from "xstate"; import { ChatSendResponse } from "@/data/dto/chat"; +import type { + UnlockMessageOutput, + UnlockMessageRequest, +} from "@/stores/chat/helper/unlock"; import { createTestChatMachine, @@ -189,7 +193,6 @@ describe("chat unlock flow", () => { ); expect(actor.getSnapshot().context).toMatchObject({ - isUnlockingHistory: false, unlockHistoryPromptVisible: true, unlockHistoryError: "Failed to unlock messages. Please try again.", }); @@ -237,7 +240,7 @@ describe("chat unlock flow", () => { ); expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull(); - expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); + expect(actor.getSnapshot().context.unlockingMessage).toBeNull(); expect(actor.getSnapshot().context.messages).toMatchObject([ { id: "msg-private-locked", @@ -485,7 +488,7 @@ describe("chat unlock flow", () => { }, ]); expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined(); - expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); + expect(actor.getSnapshot().context.unlockingMessage).toBeNull(); actor.stop(); }); @@ -536,8 +539,7 @@ describe("chat unlock flow", () => { ); expect(actor.getSnapshot().context).toMatchObject({ - isUnlockingMessage: false, - unlockingMessageId: null, + unlockingMessage: null, unlockMessageError: "not_found", unlockPaywallRequest: null, }); @@ -552,6 +554,92 @@ describe("chat unlock flow", () => { actor.stop(); }); + it("owns the active request in one object and ignores history refreshes while unlocking", async () => { + let capturedRequest: UnlockMessageRequest | null = null; + let resolveUnlock!: (output: UnlockMessageOutput) => void; + const unlockDeferred = new Promise((resolve) => { + resolveUnlock = resolve; + }); + const originalMessage = { + id: "promotion:lock-1", + content: "", + isFromAI: true, + date: "2026-07-16", + locked: true, + lockReason: "image_paywall" as const, + imagePaywalled: true, + }; + const machine = createTestChatMachine({ + historyMessages: [originalMessage], + }).provide({ + actors: { + unlockMessage: fromPromise< + UnlockMessageOutput, + UnlockMessageRequest + >(async ({ input }) => { + capturedRequest = input; + return unlockDeferred; + }), + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + actor.send({ + type: "ChatUnlockMessageRequested", + messageId: "promotion:lock-1", + remoteMessageId: "remote-1", + kind: "image", + lockType: "image_paywall", + clientLockId: "lock-1", + }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "unlockingMessage" }), + ); + + expect(actor.getSnapshot().context.unlockingMessage).toEqual({ + displayMessageId: "promotion:lock-1", + messageId: "remote-1", + kind: "image", + lockType: "image_paywall", + clientLockId: "lock-1", + }); + actor.send({ + type: "ChatNetworkHistoryLoaded", + output: { + messages: [], + localOverwritten: true, + localCount: 0, + networkCount: 0, + total: 0, + limit: 50, + }, + }); + expect(actor.getSnapshot().context.messages).toEqual([originalMessage]); + + if (!capturedRequest) throw new Error("unlock actor did not start"); + resolveUnlock({ + displayMessageId: "promotion:lock-1", + request: capturedRequest, + response: makeUnlockPrivateResponse({ + messageId: "remote-1", + image: { + type: "promotion", + url: "https://example.com/unlocked.jpg", + }, + }), + }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + expect(actor.getSnapshot().context.unlockingMessage).toBeNull(); + + actor.stop(); + }); + it("clears the insufficient credits prompt after payment succeeds", async () => { const actor = createActor(createTestChatMachine()).start(); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index d28f3c7c..0a0e4790 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -86,10 +86,11 @@ function selectChatState(state: ChatSnapshot): SelectedChatState { isLoadingMoreHistory: state.context.isLoadingMoreHistory, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, lockedHistoryCount: state.context.lockedHistoryCount, - isUnlockingHistory: state.context.isUnlockingHistory, + isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), unlockHistoryError: state.context.unlockHistoryError, - isUnlockingMessage: state.context.isUnlockingMessage, - unlockingMessageId: state.context.unlockingMessageId, + isUnlockingMessage: state.matches({ userSession: "unlockingMessage" }), + unlockingMessageId: + state.context.unlockingMessage?.displayMessageId ?? null, unlockMessageError: state.context.unlockMessageError, unlockPaywallRequest: state.context.unlockPaywallRequest, }; diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 02baa616..c2b3ee55 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -6,12 +6,16 @@ import type { ChatPromotionState } from "./helper/promotion"; export type ChatUpgradeReason = "insufficient_credits"; -export interface ChatUnlockPaywallRequest { +export interface ChatUnlockMessageRequest { displayMessageId: string; messageId?: string; kind: PendingChatUnlockKind; lockType?: ChatLockType; clientLockId?: string; +} + +export interface ChatUnlockPaywallRequest + extends ChatUnlockMessageRequest { promotion?: PendingChatPromotion; reason: string; creditBalance: number; @@ -41,14 +45,8 @@ export interface ChatState { paymentUnlockPending: boolean; unlockHistoryPromptVisible: boolean; lockedHistoryCount: number; - isUnlockingHistory: boolean; unlockHistoryError: string | null; - isUnlockingMessage: boolean; - unlockingMessageId: string | null; - unlockingMessageKind: PendingChatUnlockKind | null; - unlockingRemoteMessageId: string | null; - unlockingLockType: ChatLockType | null; - unlockingClientLockId: string | null; + unlockingMessage: ChatUnlockMessageRequest | null; unlockMessageError: string | null; unlockPaywallRequest: ChatUnlockPaywallRequest | null; } @@ -74,14 +72,8 @@ export const initialState: ChatState = { paymentUnlockPending: false, unlockHistoryPromptVisible: false, lockedHistoryCount: 0, - isUnlockingHistory: false, unlockHistoryError: null, - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, + unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, }; diff --git a/src/stores/chat/machine/session-flow.ts b/src/stores/chat/machine/session-flow.ts index 39b38632..4d240229 100644 --- a/src/stores/chat/machine/session-flow.ts +++ b/src/stores/chat/machine/session-flow.ts @@ -117,6 +117,22 @@ const guestSessionState = chatMachineSetup.createStateConfig({ const userReadyState = chatMachineSetup.createStateConfig({ on: { + ChatNetworkHistoryLoaded: [ + { + guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && + context.paymentUnlockPending && + shouldPromptUnlockHistory(event.output.messages), + actions: "showUnlockHistoryPromptFromNetwork", + }, + { + guard: ({ context }) => context.paymentUnlockPending, + actions: "applyNetworkHistoryLoadedAndClearPayment", + }, + { + actions: "applyNetworkHistoryLoaded", + }, + ], ChatSendMessage: { guard: ({ context, event }) => context.canSendMessage && event.content.trim().length > 0, @@ -158,26 +174,6 @@ const userSessionState = chatMachineSetup.createStateConfig({ target: "#chat.idle", actions: "clearChatSession", }, - ChatNetworkHistoryLoaded: [ - { - guard: ({ context, event }) => - event.type === "ChatNetworkHistoryLoaded" && - context.paymentUnlockPending && - shouldPromptUnlockHistory(event.output.messages), - target: ".ready", - actions: "showUnlockHistoryPromptFromNetwork", - }, - { - guard: ({ context }) => context.paymentUnlockPending, - target: ".ready", - actions: "applyNetworkHistoryLoadedAndClearPayment", - }, - { - guard: ({ context }) => - !context.isUnlockingHistory && !context.isUnlockingMessage, - actions: "applyNetworkHistoryLoaded", - }, - ], ChatHistoryLoadFailed: { actions: "markHistoryLoadFailed", }, diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts index ffaed215..975d54fd 100644 --- a/src/stores/chat/machine/unlock-flow.ts +++ b/src/stores/chat/machine/unlock-flow.ts @@ -33,12 +33,10 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({ const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({ paymentUnlockPending: false, unlockHistoryPromptVisible: false, - isUnlockingHistory: true, unlockHistoryError: null, })); const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({ - isUnlockingHistory: false, unlockHistoryPromptVisible: true, unlockHistoryError: "Failed to unlock messages. Please try again.", })); @@ -46,14 +44,18 @@ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({ const markUnlockMessageStartedAction = sendMachineSetup.assign( ({ event }) => { if (event.type !== "ChatUnlockMessageRequested") return {}; + const remoteMessageId = + event.remoteMessageId ?? (event.lockType ? undefined : event.messageId); return { - isUnlockingMessage: true, - unlockingMessageId: event.messageId, - unlockingMessageKind: event.kind, - unlockingRemoteMessageId: - event.remoteMessageId ?? (event.lockType ? null : event.messageId), - unlockingLockType: event.lockType ?? null, - unlockingClientLockId: event.clientLockId ?? null, + unlockingMessage: { + displayMessageId: event.messageId, + ...(remoteMessageId ? { messageId: remoteMessageId } : {}), + kind: event.kind, + ...(event.lockType ? { lockType: event.lockType } : {}), + ...(event.clientLockId + ? { clientLockId: event.clientLockId } + : {}), + }, unlockMessageError: null, unlockPaywallRequest: null, }; @@ -66,12 +68,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign( return { messages: applySingleUnlockOutput(context.messages, output), promotion: applyPromotionUnlockOutput(context.promotion, output), - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, + unlockingMessage: null, unlockMessageError: null, unlockPaywallRequest: null, creditBalance: output.response.creditBalance, @@ -88,12 +85,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign( const shouldOpenPaywall = output.response.reason !== "not_found"; return { promotion: applyPromotionUnlockOutput(context.promotion, output), - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, + unlockingMessage: null, unlockMessageError: output.response.reason, unlockPaywallRequest: shouldOpenPaywall ? { @@ -105,7 +97,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign( output.response.messageId || output.request.messageId, } : {}), - kind: context.unlockingMessageKind ?? "private", + kind: context.unlockingMessage?.kind ?? "private", ...(output.request.lockType ? { lockType: output.request.lockType } : {}), @@ -129,24 +121,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign( ); const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign( - ({ context }) => ({ - isUnlockingMessage: false, - unlockMessageError: "unlock_failed", - unlockPaywallRequest: - context.unlockingMessageId && context.unlockingMessageKind + ({ context }) => { + const request = context.unlockingMessage; + return { + unlockingMessage: null, + unlockMessageError: "unlock_failed", + unlockPaywallRequest: request ? { - displayMessageId: context.unlockingMessageId, - ...(context.unlockingRemoteMessageId - ? { messageId: context.unlockingRemoteMessageId } - : {}), - kind: context.unlockingMessageKind, - ...(context.unlockingLockType - ? { lockType: context.unlockingLockType } - : {}), - ...(context.unlockingClientLockId - ? { clientLockId: context.unlockingClientLockId } - : {}), - ...(context.promotion?.message.id === context.unlockingMessageId + ...request, + ...(context.promotion?.message.id === request.displayMessageId ? { promotion: context.promotion.session } : {}), reason: "unlock_failed", @@ -155,12 +138,8 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign( shortfallCredits: context.shortfallCredits, } : null, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, - }), + }; + }, ); const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({ @@ -199,7 +178,6 @@ const applyUnlockHistoryOutputAction = paymentUnlockPending: false, unlockHistoryPromptVisible: insufficientBalance, lockedHistoryCount, - isUnlockingHistory: false, unlockHistoryError: insufficientBalance ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` : null, @@ -225,18 +203,11 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({ invoke: { id: "unlockMessage", src: "unlockMessage", - input: ({ context }) => ({ - displayMessageId: context.unlockingMessageId ?? "", - ...(context.unlockingRemoteMessageId - ? { messageId: context.unlockingRemoteMessageId } - : {}), - ...(context.unlockingLockType - ? { lockType: context.unlockingLockType } - : {}), - ...(context.unlockingClientLockId - ? { clientLockId: context.unlockingClientLockId } - : {}), - }), + input: ({ context }) => + context.unlockingMessage ?? { + displayMessageId: "", + kind: "private", + }, onDone: [ { guard: ({ event }) => event.output.response.unlocked,