diff --git a/src/stores/chat/__tests__/chat-history-flow.test.ts b/src/stores/chat/__tests__/chat-history-flow.test.ts new file mode 100644 index 00000000..03749493 --- /dev/null +++ b/src/stores/chat/__tests__/chat-history-flow.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { createActor, fromCallback, waitFor } from "xstate"; + +import type { UiMessage } from "@/data/dto/chat"; +import type { ChatEvent } from "@/stores/chat/chat-events"; +import { chatMachine } from "@/stores/chat/chat-machine"; + +import { createTestChatMachine } from "./chat-machine.test-utils"; + +describe("chat history flow", () => { + it("enters user ready after history is loaded", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + const snapshot = actor.getSnapshot(); + expect(snapshot.context.historyLoaded).toBe(true); + + actor.stop(); + }); + + it("renders local history before network history sync finishes", async () => { + let resolveNetwork!: () => void; + const networkReleased = new Promise((resolve) => { + resolveNetwork = resolve; + }); + const localMessage: UiMessage = { + id: "local-msg", + content: "cached local message", + isFromAI: true, + date: "2026-07-02", + }; + const networkMessage: UiMessage = { + id: "network-msg", + content: "fresh network message", + isFromAI: true, + date: "2026-07-02", + }; + const machine = chatMachine.provide({ + actors: { + loadHistory: fromCallback(({ sendBack }) => { + sendBack({ + type: "ChatLocalHistoryLoaded", + output: { + messages: [localMessage], + localCount: 1, + }, + }); + void networkReleased.then(() => { + sendBack({ + type: "ChatNetworkHistoryLoaded", + output: { + messages: [networkMessage], + localOverwritten: true, + localCount: 1, + networkCount: 1, + }, + }); + }); + return () => undefined; + }), + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + expect(actor.getSnapshot().context.messages).toMatchObject([ + { id: "local-msg", content: "cached local message" }, + ]); + + resolveNetwork(); + await waitFor( + actor, + (snapshot) => snapshot.context.messages[0]?.id === "network-msg", + ); + + expect(actor.getSnapshot().context.messages).toMatchObject([ + { id: "network-msg", content: "fresh network message" }, + ]); + + actor.stop(); + }); +}); diff --git a/src/stores/chat/__tests__/chat-machine.test-utils.ts b/src/stores/chat/__tests__/chat-machine.test-utils.ts new file mode 100644 index 00000000..a25f019b --- /dev/null +++ b/src/stores/chat/__tests__/chat-machine.test-utils.ts @@ -0,0 +1,154 @@ +import { fromCallback, fromPromise } from "xstate"; + +import { + ChatSendResponse, + UnlockPrivateResponse, + type UiMessage, +} from "@/data/dto/chat"; +import { chatMachine } from "@/stores/chat/chat-machine"; +import type { ChatEvent } from "@/stores/chat/chat-events"; +import type { + UnlockMessageOutput as MachineUnlockMessageOutput, + UnlockMessageRequest, +} from "@/stores/chat/chat-machine.helpers"; + +export interface SendMessageHttpOutput { + response: ChatSendResponse; + reply: UiMessage | null; +} + +export interface UnlockHistoryOutput { + unlocked: boolean; + reason: string; + shortfallCredits: number; + messages: UiMessage[]; +} + +export interface TestUnlockMessageOutput { + messageId: string; + response: UnlockPrivateResponse; +} + +export function makeChatSendResponse(): ChatSendResponse { + return ChatSendResponse.from({ + reply: "", + audioUrl: "", + messageId: "msg-1", + isGuest: false, + timestamp: Date.now(), + image: { type: null, url: null }, + lockDetail: { + locked: false, + reason: null, + }, + }); +} + +export function makeUnlockPrivateResponse( + overrides: Partial[0]> = {}, +): UnlockPrivateResponse { + return UnlockPrivateResponse.from({ + unlocked: true, + content: "unlocked content", + audioUrl: "", + reason: "ok", + creditBalance: 90, + creditsCharged: 10, + requiredCredits: 10, + shortfallCredits: 0, + ...overrides, + }); +} + +export function createTestChatMachine( + options: { + historyMessages?: UiMessage[]; + sendMessageHttpError?: Error; + unlockHistoryOutput?: UnlockHistoryOutput; + unlockHistoryError?: Error; + unlockMessageOutput?: TestUnlockMessageOutput; + } = {}, +) { + return chatMachine.provide({ + actors: { + loadHistory: createLoadHistoryCallback(options.historyMessages ?? []), + sendMessageHttp: fromPromise< + SendMessageHttpOutput, + { content: string } + >(async () => { + if (options.sendMessageHttpError) { + throw options.sendMessageHttpError; + } + return { + response: makeChatSendResponse(), + reply: null, + }; + }), + httpMessageQueue: fromCallback(({ receive, sendBack }) => { + receive((event) => { + if (event.type !== "ChatSendMessage") return; + sendBack({ type: "ChatQueuedSendStarted" }); + sendBack({ + type: "ChatQueuedHttpDone", + output: { + response: makeChatSendResponse(), + reply: null, + }, + }); + }); + return () => undefined; + }), + unlockHistory: fromPromise(async () => { + if (options.unlockHistoryError) { + throw options.unlockHistoryError; + } + return { + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [], + ...options.unlockHistoryOutput, + }; + }), + unlockMessage: fromPromise< + MachineUnlockMessageOutput, + UnlockMessageRequest + >(async ({ input }) => { + const output = options.unlockMessageOutput ?? { + messageId: input.messageId ?? input.displayMessageId, + response: makeUnlockPrivateResponse(), + }; + return { + displayMessageId: input.displayMessageId, + request: input, + response: output.response, + }; + }), + }, + }); +} + +export function createLoadHistoryCallback( + messages: UiMessage[], + networkMessages: UiMessage[] = messages, +) { + return fromCallback(({ sendBack }) => { + sendBack({ + type: "ChatLocalHistoryLoaded", + output: { + messages, + localCount: messages.length, + }, + }); + sendBack({ + type: "ChatNetworkHistoryLoaded", + output: { + messages: networkMessages, + localOverwritten: true, + localCount: messages.length, + networkCount: networkMessages.length, + }, + }); + return () => undefined; + }); +} diff --git a/src/stores/chat/__tests__/chat-send-flow.test.ts b/src/stores/chat/__tests__/chat-send-flow.test.ts new file mode 100644 index 00000000..ae97f222 --- /dev/null +++ b/src/stores/chat/__tests__/chat-send-flow.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { createActor, fromCallback, fromPromise, waitFor } from "xstate"; + +import type { ChatEvent } from "@/stores/chat/chat-events"; +import { chatMachine } from "@/stores/chat/chat-machine"; + +import { + createLoadHistoryCallback, + createTestChatMachine, + makeChatSendResponse, + type SendMessageHttpOutput, + type UnlockHistoryOutput, +} from "./chat-machine.test-utils"; + +describe("chat send flow", () => { + it("allows multiple messages to be queued without leaving ready state", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatSendMessage", content: "hello" }); + actor.send({ type: "ChatSendMessage", content: "still there?" }); + + expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { content: "hello", isFromAI: false }, + { content: "still there?", isFromAI: false }, + ]); + + await waitFor( + actor, + (snapshot) => snapshot.context.pendingReplyCount === 0, + ); + expect(actor.getSnapshot().context.isReplyingAI).toBe(false); + + actor.stop(); + }); + + it("applies direct HTTP actor output with a type-bound action", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.send({ + type: "ChatSendImage", + imageBase64: "data:image/png;base64,image", + }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }) && + snapshot.context.pendingReplyCount === 0, + ); + + expect(actor.getSnapshot().context.isReplyingAI).toBe(false); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { content: "[Image]", isFromAI: false }, + ]); + + actor.stop(); + }); + + it("restores ready state when the direct HTTP actor fails", async () => { + const actor = createActor( + createTestChatMachine({ + sendMessageHttpError: new Error("send failed"), + }), + ).start(); + + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.send({ + type: "ChatSendImage", + imageBase64: "data:image/png;base64,image", + }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + expect(actor.getSnapshot().context.isReplyingAI).toBe(false); + + actor.stop(); + }); + + it("tracks replying state by queued batch instead of user message count", async () => { + const machine = chatMachine.provide({ + actors: { + loadHistory: createLoadHistoryCallback([]), + sendMessageHttp: fromPromise< + SendMessageHttpOutput, + { content: string } + >(async () => ({ + response: makeChatSendResponse(), + reply: null, + })), + httpMessageQueue: fromCallback(({ receive, sendBack }) => { + const pending: string[] = []; + receive((event) => { + if (event.type !== "ChatSendMessage") return; + pending.push(event.content); + if (pending.length !== 2) return; + sendBack({ type: "ChatQueuedSendStarted" }); + sendBack({ + type: "ChatQueuedHttpDone", + output: { + response: makeChatSendResponse(), + reply: null, + }, + }); + }); + return () => undefined; + }), + unlockHistory: fromPromise(async () => ({ + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [], + })), + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatSendMessage", content: "first" }); + actor.send({ type: "ChatSendMessage", content: "second" }); + + await waitFor( + actor, + (snapshot) => snapshot.context.messages.length === 2, + ); + + expect(actor.getSnapshot().context.pendingReplyCount).toBe(0); + expect(actor.getSnapshot().context.isReplyingAI).toBe(false); + + actor.stop(); + }); +}); diff --git a/src/stores/chat/__tests__/chat-session-flow.test.ts b/src/stores/chat/__tests__/chat-session-flow.test.ts new file mode 100644 index 00000000..9701ece8 --- /dev/null +++ b/src/stores/chat/__tests__/chat-session-flow.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { createActor, waitFor } from "xstate"; + +import { createTestChatMachine } from "./chat-machine.test-utils"; + +describe("chat session flow", () => { + it("keeps guest logout disabled and supports user to guest transition", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.send({ type: "ChatLogout" }); + expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.stop(); + }); + + it("allows explicit logout from user session", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatLogout" }); + expect(actor.getSnapshot().matches("idle")).toBe(true); + + actor.stop(); + }); + + it("keeps a promotion separate from normal history", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + id: "history-1", + content: "Existing history", + isFromAI: true, + date: "2026-07-13", + }, + ], + }), + ).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + actor.send({ + type: "ChatPromotionInjected", + promotion: { + promotionType: "voice", + lockType: "voice_message", + clientLockId: "promotion-1", + createdAt: 1, + }, + }); + + const context = actor.getSnapshot().context; + expect(context.messages).toHaveLength(1); + expect(context.promotion?.message).toMatchObject({ + id: "promotion:promotion-1", + locked: true, + lockReason: "voice_message", + }); + + actor.stop(); + }); + + it("switches to guest session when guest login arrives during user session", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatGuestLogin" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ guestSession: "ready" }), + ); + + actor.stop(); + }); + + it("ignores repeated user login while an authenticated user session is active", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatSendMessage", content: "keep this message" }); + await waitFor( + actor, + (snapshot) => snapshot.context.messages.length === 1, + ); + + actor.send({ type: "ChatUserLogin", token: "another-token" }); + + expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { content: "keep this message", isFromAI: false }, + ]); + + actor.stop(); + }); +}); diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-unlock-flow.test.ts similarity index 51% rename from src/stores/chat/__tests__/chat-machine.transitions.test.ts rename to src/stores/chat/__tests__/chat-unlock-flow.test.ts index e4143f99..ac92b2c9 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-unlock-flow.test.ts @@ -1,490 +1,14 @@ import { describe, expect, it } from "vitest"; -import { createActor, fromCallback, fromPromise, waitFor } from "xstate"; +import { createActor, waitFor } from "xstate"; + +import { ChatSendResponse } from "@/data/dto/chat"; import { - ChatSendResponse, - UnlockPrivateResponse, - type UiMessage, -} from "@/data/dto/chat"; -import { chatMachine } from "@/stores/chat/chat-machine"; -import type { ChatEvent } from "@/stores/chat/chat-events"; -import type { - UnlockMessageOutput as MachineUnlockMessageOutput, - UnlockMessageRequest, -} from "@/stores/chat/chat-machine.helpers"; - -interface SendMessageHttpOutput { - response: ChatSendResponse; - reply: UiMessage | null; -} - -interface UnlockHistoryOutput { - unlocked: boolean; - reason: string; - shortfallCredits: number; - messages: UiMessage[]; -} - -interface TestUnlockMessageOutput { - messageId: string; - response: UnlockPrivateResponse; -} - -function makeChatSendResponse(): ChatSendResponse { - return ChatSendResponse.from({ - reply: "", - audioUrl: "", - messageId: "msg-1", - isGuest: false, - timestamp: Date.now(), - image: { type: null, url: null }, - lockDetail: { - locked: false, - reason: null, - }, - }); -} - -function makeUnlockPrivateResponse( - overrides: Partial[0]> = {}, -): UnlockPrivateResponse { - return UnlockPrivateResponse.from({ - unlocked: true, - content: "unlocked content", - audioUrl: "", - reason: "ok", - creditBalance: 90, - creditsCharged: 10, - requiredCredits: 10, - shortfallCredits: 0, - ...overrides, - }); -} - -function createTestChatMachine( - options: { - historyMessages?: UiMessage[]; - sendMessageHttpError?: Error; - unlockHistoryOutput?: UnlockHistoryOutput; - unlockHistoryError?: Error; - unlockMessageOutput?: TestUnlockMessageOutput; - } = {}, -) { - return chatMachine.provide({ - actors: { - loadHistory: createLoadHistoryCallback(options.historyMessages ?? []), - sendMessageHttp: fromPromise< - SendMessageHttpOutput, - { content: string } - >(async () => { - if (options.sendMessageHttpError) { - throw options.sendMessageHttpError; - } - return { - response: makeChatSendResponse(), - reply: null, - }; - }), - httpMessageQueue: fromCallback(({ receive, sendBack }) => { - receive((event) => { - if (event.type !== "ChatSendMessage") return; - sendBack({ type: "ChatQueuedSendStarted" }); - sendBack({ - type: "ChatQueuedHttpDone", - output: { - response: makeChatSendResponse(), - reply: null, - }, - }); - }); - return () => undefined; - }), - unlockHistory: fromPromise(async () => { - if (options.unlockHistoryError) { - throw options.unlockHistoryError; - } - return { - unlocked: true, - reason: "ok", - shortfallCredits: 0, - messages: [], - ...options.unlockHistoryOutput, - }; - }), - unlockMessage: fromPromise< - MachineUnlockMessageOutput, - UnlockMessageRequest - >(async ({ input }) => { - const output = options.unlockMessageOutput ?? { - messageId: input.messageId ?? input.displayMessageId, - response: makeUnlockPrivateResponse(), - }; - return { - displayMessageId: input.displayMessageId, - request: input, - response: output.response, - }; - }), - }, - }); -} - -function createLoadHistoryCallback( - messages: UiMessage[], - networkMessages: UiMessage[] = messages, -) { - return fromCallback(({ sendBack }) => { - sendBack({ - type: "ChatLocalHistoryLoaded", - output: { - messages, - localCount: messages.length, - }, - }); - sendBack({ - type: "ChatNetworkHistoryLoaded", - output: { - messages: networkMessages, - localOverwritten: true, - localCount: messages.length, - networkCount: networkMessages.length, - }, - }); - return () => undefined; - }); -} - -describe("chatMachine transitions", () => { - it("keeps guest logout disabled and supports user to guest transition", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatGuestLogin" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - actor.send({ type: "ChatLogout" }); - expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatGuestLogin" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - actor.stop(); - }); - - it("allows explicit logout from user session", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatLogout" }); - expect(actor.getSnapshot().matches("idle")).toBe(true); - - actor.stop(); - }); - - it("enters user ready after history is loaded", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - const snapshot = actor.getSnapshot(); - expect(snapshot.context.historyLoaded).toBe(true); - - actor.stop(); - }); - - it("keeps a promotion separate from normal history", async () => { - const actor = createActor( - createTestChatMachine({ - historyMessages: [ - { - id: "history-1", - content: "Existing history", - isFromAI: true, - date: "2026-07-13", - }, - ], - }), - ).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - actor.send({ - type: "ChatPromotionInjected", - promotion: { - promotionType: "voice", - lockType: "voice_message", - clientLockId: "promotion-1", - createdAt: 1, - }, - }); - - const context = actor.getSnapshot().context; - expect(context.messages).toHaveLength(1); - expect(context.promotion?.message).toMatchObject({ - id: "promotion:promotion-1", - locked: true, - lockReason: "voice_message", - }); - - actor.stop(); - }); - - it("renders local history before network history sync finishes", async () => { - let resolveNetwork!: () => void; - const networkReleased = new Promise((resolve) => { - resolveNetwork = resolve; - }); - const localMessage: UiMessage = { - id: "local-msg", - content: "cached local message", - isFromAI: true, - date: "2026-07-02", - }; - const networkMessage: UiMessage = { - id: "network-msg", - content: "fresh network message", - isFromAI: true, - date: "2026-07-02", - }; - const machine = chatMachine.provide({ - actors: { - loadHistory: fromCallback(({ sendBack }) => { - sendBack({ - type: "ChatLocalHistoryLoaded", - output: { - messages: [localMessage], - localCount: 1, - }, - }); - void networkReleased.then(() => { - sendBack({ - type: "ChatNetworkHistoryLoaded", - output: { - messages: [networkMessage], - localOverwritten: true, - localCount: 1, - networkCount: 1, - }, - }); - }); - return () => undefined; - }), - }, - }); - const actor = createActor(machine).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - expect(actor.getSnapshot().context.messages).toMatchObject([ - { id: "local-msg", content: "cached local message" }, - ]); - - resolveNetwork(); - await waitFor( - actor, - (snapshot) => snapshot.context.messages[0]?.id === "network-msg", - ); - - expect(actor.getSnapshot().context.messages).toMatchObject([ - { id: "network-msg", content: "fresh network message" }, - ]); - - actor.stop(); - }); - - it("switches to guest session when guest login arrives during user session", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatGuestLogin" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - actor.stop(); - }); - - it("ignores repeated user login while an authenticated user session is active", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatSendMessage", content: "keep this message" }); - await waitFor( - actor, - (snapshot) => snapshot.context.messages.length === 1, - ); - - actor.send({ type: "ChatUserLogin", token: "another-token" }); - - expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); - expect(actor.getSnapshot().context.messages).toMatchObject([ - { content: "keep this message", isFromAI: false }, - ]); - - actor.stop(); - }); - - it("allows multiple messages to be queued without leaving ready state", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatSendMessage", content: "hello" }); - actor.send({ type: "ChatSendMessage", content: "still there?" }); - - expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); - expect(actor.getSnapshot().context.messages).toMatchObject([ - { content: "hello", isFromAI: false }, - { content: "still there?", isFromAI: false }, - ]); - - await waitFor( - actor, - (snapshot) => snapshot.context.pendingReplyCount === 0, - ); - expect(actor.getSnapshot().context.isReplyingAI).toBe(false); - - actor.stop(); - }); - - it("applies direct HTTP actor output with a type-bound action", async () => { - const actor = createActor(createTestChatMachine()).start(); - - actor.send({ type: "ChatGuestLogin" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - actor.send({ - type: "ChatSendImage", - imageBase64: "data:image/png;base64,image", - }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }) && - snapshot.context.pendingReplyCount === 0, - ); - - expect(actor.getSnapshot().context.isReplyingAI).toBe(false); - expect(actor.getSnapshot().context.messages).toMatchObject([ - { content: "[Image]", isFromAI: false }, - ]); - - actor.stop(); - }); - - it("restores ready state when the direct HTTP actor fails", async () => { - const actor = createActor( - createTestChatMachine({ - sendMessageHttpError: new Error("send failed"), - }), - ).start(); - - actor.send({ type: "ChatGuestLogin" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - actor.send({ - type: "ChatSendImage", - imageBase64: "data:image/png;base64,image", - }); - await waitFor(actor, (snapshot) => - snapshot.matches({ guestSession: "ready" }), - ); - - expect(actor.getSnapshot().context.isReplyingAI).toBe(false); - - actor.stop(); - }); - - it("tracks replying state by queued batch instead of user message count", async () => { - const machine = chatMachine.provide({ - actors: { - loadHistory: createLoadHistoryCallback([]), - sendMessageHttp: fromPromise< - SendMessageHttpOutput, - { content: string } - >(async () => ({ - response: makeChatSendResponse(), - reply: null, - })), - httpMessageQueue: fromCallback(({ receive, sendBack }) => { - const pending: string[] = []; - receive((event) => { - if (event.type !== "ChatSendMessage") return; - pending.push(event.content); - if (pending.length !== 2) return; - sendBack({ type: "ChatQueuedSendStarted" }); - sendBack({ - type: "ChatQueuedHttpDone", - output: { - response: makeChatSendResponse(), - reply: null, - }, - }); - }); - return () => undefined; - }), - unlockHistory: fromPromise(async () => ({ - unlocked: true, - reason: "ok", - shortfallCredits: 0, - messages: [], - })), - }, - }); - const actor = createActor(machine).start(); - - actor.send({ type: "ChatUserLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ userSession: "ready" }), - ); - - actor.send({ type: "ChatSendMessage", content: "first" }); - actor.send({ type: "ChatSendMessage", content: "second" }); - - await waitFor( - actor, - (snapshot) => snapshot.context.messages.length === 2, - ); - - expect(actor.getSnapshot().context.pendingReplyCount).toBe(0); - expect(actor.getSnapshot().context.isReplyingAI).toBe(false); - - actor.stop(); - }); + createTestChatMachine, + makeUnlockPrivateResponse, +} from "./chat-machine.test-utils"; +describe("chat unlock flow", () => { it("prompts before unlocking multiple locked history messages after payment", async () => { const actor = createActor( createTestChatMachine({ diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 1c8f2227..31543709 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -1,824 +1,17 @@ import { - setup, - type ActionFunction, - type DoneActorEvent, - type ErrorActorEvent, - type EventObject, -} from "xstate"; + chatMachineSetup, + chatRootStateConfig, +} from "./machine/session-flow"; +import { initialState } from "./chat-state"; -import { todayString } from "@/utils/date"; -import { Logger } from "@/utils/logger"; - -import type { ChatEvent } from "./chat-events"; -import { - httpMessageQueueActor, - loadHistoryActor, - sendMessageHttpActor, - unlockHistoryActor, - unlockMessageActor, -} from "./chat-machine.actors"; -import { - applyHistoryLoadedOutput, - applyHttpSendOutput, - applyNetworkHistoryLoadedOutput, - applyPromotionUnlockOutput, - applySingleUnlockOutput, - beginPendingReply, - countLockedHistoryMessages, - createChatPromotionState, - finishPendingReply, - shouldPromptUnlockHistory, - type HttpSendOutput, - type UnlockMessageOutput, -} from "./chat-machine.helpers"; -import { initialState, type ChatState } from "./chat-state"; -import type { UnlockHistoryOutput } from "./chat-unlock-flow"; - -// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { ChatState } from "./chat-state"; export { initialState } from "./chat-state"; export type { ChatEvent } from "./chat-events"; -const baseChatMachineSetup = setup({ - types: { - context: {} as ChatState, - events: {} as ChatEvent, - }, - actors: { - loadHistory: loadHistoryActor, - sendMessageHttp: sendMessageHttpActor, - httpMessageQueue: httpMessageQueueActor, - unlockHistory: unlockHistoryActor, - unlockMessage: unlockMessageActor, - }, -}); - -const sendLog = new Logger("StoresChatChatSendFlow"); -const mediaLog = new Logger("StoresChatChatMediaFlow"); - -const enqueueMessageAction = baseChatMachineSetup.sendTo( - "messageQueue", - ({ event }) => event, -); - -const startGuestSessionAction = baseChatMachineSetup.assign( - ({ context }) => ({ - ...initialState, - promotion: context.promotion, - }), -); - -const startUserSessionAction = baseChatMachineSetup.assign( - ({ context }) => ({ - ...initialState, - promotion: context.promotion, - }), -); - -const clearChatSessionAction = baseChatMachineSetup.assign(() => ({ - ...initialState, -})); - -const injectPromotionAction = baseChatMachineSetup.assign(({ event }) => { - if (event.type !== "ChatPromotionInjected") return {}; - return { - promotion: createChatPromotionState( - event.promotion, - event.messageId, - ), - }; -}); - -const clearPromotionAction = baseChatMachineSetup.assign({ promotion: null }); - -const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign( - ({ event }) => { - if (event.type !== "ChatLocalHistoryLoaded") return {}; - return applyHistoryLoadedOutput(event.output); - }, -); - -const applyNetworkHistoryLoadedAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatNetworkHistoryLoaded") return {}; - return applyNetworkHistoryLoadedOutput(context, event.output); - }, -); - -const applyNetworkHistoryLoadedAndClearPaymentAction = - baseChatMachineSetup.assign(({ context, event }) => { - if (event.type !== "ChatNetworkHistoryLoaded") return {}; - return { - ...applyNetworkHistoryLoadedOutput(context, event.output), - paymentUnlockPending: false, - }; - }); - -const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatNetworkHistoryLoaded") return {}; - const nextHistory = applyNetworkHistoryLoadedOutput(context, event.output); - return { - ...nextHistory, - paymentUnlockPending: false, - unlockHistoryPromptVisible: true, - lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages), - unlockHistoryError: null, - }; - }, -); - -const markHistoryLoadFailedAction = baseChatMachineSetup.assign({ - historyLoaded: true, -}); - -const appendGuestUserMessageAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatSendMessage") return {}; - - const today = todayString(); - sendLog.debug("[chat-machine] appendGuestUserMessage", { - contentLength: event.content.length, - contentPreview: event.content.slice(0, 50), - quotaMode: "server controlled", - isReplyingAI: context.isReplyingAI, - }); - - return { - messages: [ - ...context.messages, - { - content: event.content, - isFromAI: false, - date: today, - }, - ], - upgradePromptVisible: false, - upgradeReason: null, - }; - }, -); - -const appendUserMessageAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatSendMessage") return {}; - - const today = todayString(); - sendLog.debug("[chat-machine] appendUserMessage", { - contentLength: event.content.length, - contentPreview: event.content.slice(0, 50), - isReplyingAI: context.isReplyingAI, - }); - - return { - messages: [ - ...context.messages, - { - content: event.content, - isFromAI: false, - date: today, - }, - ], - upgradePromptVisible: false, - upgradeReason: null, - }; - }, -); - -const appendQueuedSendErrorMessageAction = baseChatMachineSetup.assign( - ({ context }) => { - const messages = [ - ...context.messages, - { - content: "Something went wrong. Try sending again?", - isFromAI: true, - date: todayString(), - }, - ]; - return { messages, ...finishPendingReply(context) }; - }, -); - -const markQueuedSendStartedAction = baseChatMachineSetup.assign( - ({ context }) => beginPendingReply(context), -); - -const applyQueuedHttpOutputAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatQueuedHttpDone") return {}; - return applyHttpSendOutput(context, event.output); - }, -); - -const clearUpgradePromptAction = baseChatMachineSetup.assign(() => ({ - upgradePromptVisible: false, - upgradeReason: null, - canSendMessage: true, - requiredCredits: 0, - shortfallCredits: 0, -})); - -const appendGuestUserImageAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatSendImage") return {}; - - const today = todayString(); - mediaLog.debug("[chat-machine] appendGuestUserImage", { - oldMessagesCount: context.messages.length, - quotaMode: "server controlled", - }); - return { - messages: [ - ...context.messages, - { - content: "[Image]", - isFromAI: false, - date: today, - imageUrl: event.imageBase64, - }, - ], - ...beginPendingReply(context), - upgradePromptVisible: false, - upgradeReason: null, - }; - }, -); - -const appendUserImageAction = baseChatMachineSetup.assign( - ({ context, event }) => { - if (event.type !== "ChatSendImage") return {}; - - const today = todayString(); - mediaLog.debug("[chat-machine] appendUserImage", { - oldMessagesCount: context.messages.length, - }); - - return { - messages: [ - ...context.messages, - { - content: "[Image]", - isFromAI: false, - date: today, - imageUrl: event.imageBase64, - }, - ], - ...beginPendingReply(context), - upgradePromptVisible: false, - upgradeReason: null, - }; - }, -); - -const markPaymentUnlockPendingAction = baseChatMachineSetup.assign(() => ({ - paymentUnlockPending: true, - unlockHistoryError: null, -})); - -const showUnlockHistoryPromptAction = baseChatMachineSetup.assign( - ({ context }) => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: true, - lockedHistoryCount: countLockedHistoryMessages(context.messages), - unlockHistoryError: null, - }), -); - -const dismissUnlockHistoryPromptAction = baseChatMachineSetup.assign(() => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: false, - unlockHistoryError: null, -})); - -const markUnlockHistoryStartedAction = baseChatMachineSetup.assign(() => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: false, - isUnlockingHistory: true, - unlockHistoryError: null, -})); - -const markUnlockHistoryFailedAction = baseChatMachineSetup.assign(() => ({ - isUnlockingHistory: false, - unlockHistoryPromptVisible: true, - unlockHistoryError: "Failed to unlock messages. Please try again.", -})); - -const markUnlockMessageStartedAction = baseChatMachineSetup.assign( - ({ event }) => { - if (event.type !== "ChatUnlockMessageRequested") return {}; - 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, - unlockMessageError: null, - unlockPaywallRequest: null, - }; - }, -); - -const applyUnlockMessageSucceededAction = baseChatMachineSetup.assign( - ({ context, event }) => { - const { output } = event as unknown as DoneActorEvent; - return { - messages: applySingleUnlockOutput(context.messages, output), - promotion: applyPromotionUnlockOutput(context.promotion, output), - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, - unlockMessageError: null, - unlockPaywallRequest: null, - creditBalance: output.response.creditBalance, - creditsCharged: output.response.creditsCharged, - requiredCredits: 0, - shortfallCredits: 0, - }; - }, -); - -const requestUnlockPaymentFromOutputAction = baseChatMachineSetup.assign( - ({ context, event }) => { - const { output } = event as unknown as DoneActorEvent; - return { - promotion: applyPromotionUnlockOutput(context.promotion, output), - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, - unlockMessageError: output.response.reason, - unlockPaywallRequest: { - displayMessageId: - output.response.messageId || output.displayMessageId, - ...(output.response.messageId || output.request.messageId - ? { - messageId: - output.response.messageId || output.request.messageId, - } - : {}), - kind: context.unlockingMessageKind ?? "private", - ...(output.request.lockType - ? { lockType: output.request.lockType } - : {}), - ...(output.request.clientLockId - ? { clientLockId: output.request.clientLockId } - : {}), - ...(context.promotion?.message.id === output.displayMessageId - ? { promotion: context.promotion.session } - : {}), - reason: output.response.reason, - creditBalance: output.response.creditBalance, - requiredCredits: output.response.requiredCredits, - shortfallCredits: output.response.shortfallCredits, - }, - creditBalance: output.response.creditBalance, - requiredCredits: output.response.requiredCredits, - shortfallCredits: output.response.shortfallCredits, - }; - }, -); - -const requestUnlockPaymentFromErrorAction = baseChatMachineSetup.assign( - ({ context }) => ({ - isUnlockingMessage: false, - unlockMessageError: "unlock_failed", - unlockPaywallRequest: - context.unlockingMessageId && context.unlockingMessageKind - ? { - 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 - ? { promotion: context.promotion.session } - : {}), - reason: "unlock_failed", - creditBalance: context.creditBalance, - requiredCredits: context.requiredCredits, - shortfallCredits: context.shortfallCredits, - } - : null, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockingRemoteMessageId: null, - unlockingLockType: null, - unlockingClientLockId: null, - }), -); - -const clearUnlockPaywallRequestAction = baseChatMachineSetup.assign(() => ({ - unlockPaywallRequest: null, -})); - -type ChatActorAction = ActionFunction< - ChatState, - TEvent, - ChatEvent, - undefined, - never, - never, - never, - never, - never ->; - -function createChatActorActionSetup() { - const actorActionSetup = setup({ - types: { - context: {} as ChatState, - events: {} as TEvent, - }, - }); - return { - assign( - assignment: Parameters[0], - ): ChatActorAction { - // Actor lifecycle events are expression events, not public machine events. - return actorActionSetup.assign(assignment) as unknown as ChatActorAction; - }, - }; -} - -const sendMessageDoneActionSetup = - createChatActorActionSetup>(); -const unlockHistoryDoneActionSetup = - createChatActorActionSetup>(); -const actorErrorActionSetup = createChatActorActionSetup(); - -const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign( - ({ context, event }) => applyHttpSendOutput(context, event.output), -); - -const markHttpSendFailedAction = actorErrorActionSetup.assign({ - isReplyingAI: false, -}); - -const applyUnlockHistoryOutputAction = - unlockHistoryDoneActionSetup.assign(({ event }) => { - const lockedHistoryCount = countLockedHistoryMessages( - event.output.messages, - ); - const insufficientBalance = - !event.output.unlocked && - event.output.reason === "insufficient_balance"; - return { - messages: event.output.messages, - historyLoaded: true, - paymentUnlockPending: false, - unlockHistoryPromptVisible: insufficientBalance, - lockedHistoryCount, - isUnlockingHistory: false, - unlockHistoryError: insufficientBalance - ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` - : null, - }; - }); - -const chatMachineSetup = baseChatMachineSetup.extend({ - actions: { - enqueueMessage: enqueueMessageAction, - startGuestSession: startGuestSessionAction, - startUserSession: startUserSessionAction, - clearChatSession: clearChatSessionAction, - injectPromotion: injectPromotionAction, - clearPromotion: clearPromotionAction, - applyLocalHistoryLoaded: applyLocalHistoryLoadedAction, - applyNetworkHistoryLoaded: applyNetworkHistoryLoadedAction, - applyNetworkHistoryLoadedAndClearPayment: - applyNetworkHistoryLoadedAndClearPaymentAction, - showUnlockHistoryPromptFromNetwork: - showUnlockHistoryPromptFromNetworkAction, - markHistoryLoadFailed: markHistoryLoadFailedAction, - appendGuestUserMessage: appendGuestUserMessageAction, - appendUserMessage: appendUserMessageAction, - appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction, - markQueuedSendStarted: markQueuedSendStartedAction, - applyQueuedHttpOutput: applyQueuedHttpOutputAction, - clearUpgradePrompt: clearUpgradePromptAction, - appendGuestUserImage: appendGuestUserImageAction, - appendUserImage: appendUserImageAction, - markPaymentUnlockPending: markPaymentUnlockPendingAction, - showUnlockHistoryPrompt: showUnlockHistoryPromptAction, - dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction, - markUnlockHistoryStarted: markUnlockHistoryStartedAction, - markUnlockHistoryFailed: markUnlockHistoryFailedAction, - markUnlockMessageStarted: markUnlockMessageStartedAction, - applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction, - requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction, - requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction, - clearUnlockPaywallRequest: clearUnlockPaywallRequestAction, - }, -}); - export const chatMachine = chatMachineSetup.createMachine({ id: "chat", - initial: "idle", context: initialState, - on: { - ChatPromotionInjected: { actions: "injectPromotion" }, - ChatPromotionCleared: { actions: "clearPromotion" }, - }, - states: { - idle: { - on: { - ChatGuestLogin: { - target: "#chat.guestSession", - actions: "startGuestSession", - }, - ChatUserLogin: { - target: "#chat.userSession", - actions: "startUserSession", - }, - }, - }, - - guestSession: { - invoke: [ - { - id: "messageQueue", - src: "httpMessageQueue", - }, - { - id: "loadHistory", - src: "loadHistory", - }, - ], - on: { - ChatUserLogin: { - target: "#chat.userSession", - actions: "startUserSession", - }, - ChatNetworkHistoryLoaded: { - actions: "applyNetworkHistoryLoaded", - }, - ChatHistoryLoadFailed: { - actions: "markHistoryLoadFailed", - }, - ChatQueuedSendStarted: { - actions: "markQueuedSendStarted", - }, - ChatQueuedHttpDone: { - actions: "applyQueuedHttpOutput", - }, - ChatQueuedSendError: { - actions: "appendQueuedSendErrorMessage", - }, - }, - initial: "initializing", - states: { - initializing: { - on: { - ChatLocalHistoryLoaded: { - target: "ready", - actions: "applyLocalHistoryLoaded", - }, - ChatNetworkHistoryLoaded: { - target: "ready", - actions: "applyNetworkHistoryLoaded", - }, - ChatHistoryLoadFailed: { - target: "ready", - actions: "markHistoryLoadFailed", - }, - }, - }, - ready: { - on: { - ChatSendMessage: { - actions: ["appendGuestUserMessage", "enqueueMessage"], - guard: ({ context, event }) => - context.canSendMessage && event.content.trim().length > 0, - }, - ChatSendImage: { - actions: "appendGuestUserImage", - target: "sending", - }, - // 游客不支持翻页历史,发送消息统一走 HTTP 队列。 - }, - }, - sending: { - invoke: { - src: "sendMessageHttp", - input: ({ event }) => ({ - content: event.type === "ChatSendMessage" ? event.content : "", - }), - onDone: { - target: "ready", - actions: applyHttpSendOutputAction, - }, - onError: { - target: "ready", - actions: markHttpSendFailedAction, - }, - }, - }, - }, - }, - - userSession: { - invoke: [ - { - id: "messageQueue", - src: "httpMessageQueue", - }, - { - id: "loadHistory", - src: "loadHistory", - }, - ], - on: { - ChatGuestLogin: { - target: "#chat.guestSession", - actions: "startGuestSession", - }, - ChatLogout: { - 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", - }, - ChatQueuedSendStarted: { - actions: "markQueuedSendStarted", - }, - ChatQueuedHttpDone: { - actions: "applyQueuedHttpOutput", - }, - ChatQueuedSendError: { - actions: "appendQueuedSendErrorMessage", - }, - ChatPaymentSucceeded: [ - { - guard: ({ context }) => - context.historyLoaded && - shouldPromptUnlockHistory(context.messages), - actions: ["clearUpgradePrompt", "showUnlockHistoryPrompt"], - }, - { - guard: ({ context }) => !context.historyLoaded, - actions: ["clearUpgradePrompt", "markPaymentUnlockPending"], - }, - { - guard: ({ context }) => context.historyLoaded, - actions: ["clearUpgradePrompt", "dismissUnlockHistoryPrompt"], - }, - ], - ChatUnlockHistoryConfirmed: { - target: ".unlockingHistory", - actions: "markUnlockHistoryStarted", - }, - ChatUnlockHistoryDismissed: { - actions: "dismissUnlockHistoryPrompt", - }, - ChatUnlockPaywallNavigationConsumed: { - actions: "clearUnlockPaywallRequest", - }, - }, - initial: "initializing", - states: { - initializing: { - on: { - ChatLocalHistoryLoaded: { - target: "ready", - actions: "applyLocalHistoryLoaded", - }, - ChatNetworkHistoryLoaded: [ - { - guard: ({ context, event }) => - event.type === "ChatNetworkHistoryLoaded" && - context.paymentUnlockPending && - shouldPromptUnlockHistory(event.output.messages), - target: "ready", - actions: "showUnlockHistoryPromptFromNetwork", - }, - { - target: "ready", - actions: "applyNetworkHistoryLoadedAndClearPayment", - }, - ], - ChatHistoryLoadFailed: { - target: "ready", - actions: "markHistoryLoadFailed", - }, - }, - }, - ready: { - on: { - ChatSendMessage: { - guard: ({ context, event }) => - context.canSendMessage && event.content.trim().length > 0, - actions: ["appendUserMessage", "enqueueMessage"], - }, - ChatSendImage: { - actions: "appendUserImage", - }, - ChatUnlockMessageRequested: { - guard: ({ event }) => event.messageId.trim().length > 0, - target: "unlockingMessage", - actions: "markUnlockMessageStarted", - }, - }, - }, - sendingViaHttp: { - invoke: { - src: "sendMessageHttp", - input: ({ event }) => ({ - content: event.type === "ChatSendMessage" ? event.content : "", - }), - onDone: { - target: "ready", - actions: applyHttpSendOutputAction, - }, - onError: { - target: "ready", - actions: markHttpSendFailedAction, - }, - }, - }, - unlockingHistory: { - invoke: { - id: "unlockHistory", - src: "unlockHistory", - onDone: { - target: "ready", - actions: applyUnlockHistoryOutputAction, - }, - onError: { - target: "ready", - actions: "markUnlockHistoryFailed", - }, - }, - }, - unlockingMessage: { - 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 } - : {}), - }), - onDone: [ - { - guard: ({ event }) => event.output.response.unlocked, - target: "ready", - actions: "applyUnlockMessageSucceeded", - }, - { - target: "ready", - actions: "requestUnlockPaymentFromOutput", - }, - ], - onError: { - target: "ready", - actions: "requestUnlockPaymentFromError", - }, - }, - }, - }, - }, - }, + ...chatRootStateConfig, }); export type ChatMachine = typeof chatMachine; diff --git a/src/stores/chat/machine/history-flow.ts b/src/stores/chat/machine/history-flow.ts new file mode 100644 index 00000000..9d1c6657 --- /dev/null +++ b/src/stores/chat/machine/history-flow.ts @@ -0,0 +1,104 @@ +import { + applyHistoryLoadedOutput, + applyNetworkHistoryLoadedOutput, + countLockedHistoryMessages, + shouldPromptUnlockHistory, +} from "../chat-machine.helpers"; +import { baseChatMachineSetup } from "./setup"; + +const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign( + ({ event }) => { + if (event.type !== "ChatLocalHistoryLoaded") return {}; + return applyHistoryLoadedOutput(event.output); + }, +); + +const applyNetworkHistoryLoadedAction = baseChatMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + return applyNetworkHistoryLoadedOutput(context, event.output); + }, +); + +const applyNetworkHistoryLoadedAndClearPaymentAction = + baseChatMachineSetup.assign(({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + return { + ...applyNetworkHistoryLoadedOutput(context, event.output), + paymentUnlockPending: false, + }; + }); + +const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + const nextHistory = applyNetworkHistoryLoadedOutput(context, event.output); + return { + ...nextHistory, + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages), + unlockHistoryError: null, + }; + }, +); + +const markHistoryLoadFailedAction = baseChatMachineSetup.assign({ + historyLoaded: true, +}); + +export const historyMachineSetup = baseChatMachineSetup.extend({ + actions: { + applyLocalHistoryLoaded: applyLocalHistoryLoadedAction, + applyNetworkHistoryLoaded: applyNetworkHistoryLoadedAction, + applyNetworkHistoryLoadedAndClearPayment: + applyNetworkHistoryLoadedAndClearPaymentAction, + showUnlockHistoryPromptFromNetwork: + showUnlockHistoryPromptFromNetworkAction, + markHistoryLoadFailed: markHistoryLoadFailedAction, + }, +}); + +export const guestInitializingState = historyMachineSetup.createStateConfig({ + on: { + ChatLocalHistoryLoaded: { + target: "ready", + actions: "applyLocalHistoryLoaded", + }, + ChatNetworkHistoryLoaded: { + target: "ready", + actions: "applyNetworkHistoryLoaded", + }, + ChatHistoryLoadFailed: { + target: "ready", + actions: "markHistoryLoadFailed", + }, + }, +}); + +export const userInitializingState = historyMachineSetup.createStateConfig({ + on: { + ChatLocalHistoryLoaded: { + target: "ready", + actions: "applyLocalHistoryLoaded", + }, + ChatNetworkHistoryLoaded: [ + { + guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && + context.paymentUnlockPending && + shouldPromptUnlockHistory(event.output.messages), + target: "ready", + actions: "showUnlockHistoryPromptFromNetwork", + }, + { + target: "ready", + actions: "applyNetworkHistoryLoadedAndClearPayment", + }, + ], + ChatHistoryLoadFailed: { + target: "ready", + actions: "markHistoryLoadFailed", + }, + }, +}); diff --git a/src/stores/chat/machine/send-flow.ts b/src/stores/chat/machine/send-flow.ts new file mode 100644 index 00000000..60cbbcd2 --- /dev/null +++ b/src/stores/chat/machine/send-flow.ts @@ -0,0 +1,233 @@ +import { type DoneActorEvent, type ErrorActorEvent } from "xstate"; + +import { todayString } from "@/utils/date"; +import { Logger } from "@/utils/logger"; + +import { + applyHttpSendOutput, + beginPendingReply, + finishPendingReply, + type HttpSendOutput, +} from "../chat-machine.helpers"; +import { historyMachineSetup } from "./history-flow"; +import { createChatActorActionSetup } from "./setup"; + +const sendLog = new Logger("StoresChatChatSendFlow"); +const mediaLog = new Logger("StoresChatChatMediaFlow"); + +const enqueueMessageAction = historyMachineSetup.sendTo( + "messageQueue", + ({ event }) => event, +); + +const appendGuestUserMessageAction = historyMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatSendMessage") return {}; + + const today = todayString(); + sendLog.debug("[chat-machine] appendGuestUserMessage", { + contentLength: event.content.length, + contentPreview: event.content.slice(0, 50), + quotaMode: "server controlled", + isReplyingAI: context.isReplyingAI, + }); + + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: today, + }, + ], + upgradePromptVisible: false, + upgradeReason: null, + }; + }, +); + +const appendUserMessageAction = historyMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatSendMessage") return {}; + + const today = todayString(); + sendLog.debug("[chat-machine] appendUserMessage", { + contentLength: event.content.length, + contentPreview: event.content.slice(0, 50), + isReplyingAI: context.isReplyingAI, + }); + + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: today, + }, + ], + upgradePromptVisible: false, + upgradeReason: null, + }; + }, +); + +const appendQueuedSendErrorMessageAction = historyMachineSetup.assign( + ({ context }) => { + const messages = [ + ...context.messages, + { + content: "Something went wrong. Try sending again?", + isFromAI: true, + date: todayString(), + }, + ]; + return { messages, ...finishPendingReply(context) }; + }, +); + +const markQueuedSendStartedAction = historyMachineSetup.assign( + ({ context }) => beginPendingReply(context), +); + +const applyQueuedHttpOutputAction = historyMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatQueuedHttpDone") return {}; + return applyHttpSendOutput(context, event.output); + }, +); + +const clearUpgradePromptAction = historyMachineSetup.assign(() => ({ + upgradePromptVisible: false, + upgradeReason: null, + canSendMessage: true, + requiredCredits: 0, + shortfallCredits: 0, +})); + +const appendGuestUserImageAction = historyMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatSendImage") return {}; + + const today = todayString(); + mediaLog.debug("[chat-machine] appendGuestUserImage", { + oldMessagesCount: context.messages.length, + quotaMode: "server controlled", + }); + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: today, + imageUrl: event.imageBase64, + }, + ], + ...beginPendingReply(context), + upgradePromptVisible: false, + upgradeReason: null, + }; + }, +); + +const appendUserImageAction = historyMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatSendImage") return {}; + + const today = todayString(); + mediaLog.debug("[chat-machine] appendUserImage", { + oldMessagesCount: context.messages.length, + }); + + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: today, + imageUrl: event.imageBase64, + }, + ], + ...beginPendingReply(context), + upgradePromptVisible: false, + upgradeReason: null, + }; + }, +); + +export const sendMachineSetup = historyMachineSetup.extend({ + actions: { + enqueueMessage: enqueueMessageAction, + appendGuestUserMessage: appendGuestUserMessageAction, + appendUserMessage: appendUserMessageAction, + appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction, + markQueuedSendStarted: markQueuedSendStartedAction, + applyQueuedHttpOutput: applyQueuedHttpOutputAction, + clearUpgradePrompt: clearUpgradePromptAction, + appendGuestUserImage: appendGuestUserImageAction, + appendUserImage: appendUserImageAction, + }, +}); + +const sendMessageDoneActionSetup = + createChatActorActionSetup>(); +const actorErrorActionSetup = createChatActorActionSetup(); + +const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign( + ({ context, event }) => applyHttpSendOutput(context, event.output), +); + +const markHttpSendFailedAction = actorErrorActionSetup.assign({ + isReplyingAI: false, +}); + +export const guestReadyState = sendMachineSetup.createStateConfig({ + on: { + ChatSendMessage: { + actions: ["appendGuestUserMessage", "enqueueMessage"], + guard: ({ context, event }) => + context.canSendMessage && event.content.trim().length > 0, + }, + ChatSendImage: { + actions: "appendGuestUserImage", + target: "sending", + }, + }, +}); + +export const guestSendingState = sendMachineSetup.createStateConfig({ + invoke: { + src: "sendMessageHttp", + input: ({ event }) => ({ + content: event.type === "ChatSendMessage" ? event.content : "", + }), + onDone: { + target: "ready", + actions: applyHttpSendOutputAction, + }, + onError: { + target: "ready", + actions: markHttpSendFailedAction, + }, + }, +}); + +export const userSendingViaHttpState = sendMachineSetup.createStateConfig({ + invoke: { + src: "sendMessageHttp", + input: ({ event }) => ({ + content: event.type === "ChatSendMessage" ? event.content : "", + }), + onDone: { + target: "ready", + actions: applyHttpSendOutputAction, + }, + onError: { + target: "ready", + actions: markHttpSendFailedAction, + }, + }, +}); diff --git a/src/stores/chat/machine/session-flow.ts b/src/stores/chat/machine/session-flow.ts new file mode 100644 index 00000000..d4f0f421 --- /dev/null +++ b/src/stores/chat/machine/session-flow.ts @@ -0,0 +1,232 @@ +import { + createChatPromotionState, + shouldPromptUnlockHistory, +} from "../chat-machine.helpers"; +import { initialState } from "../chat-state"; +import { + guestInitializingState, + userInitializingState, +} from "./history-flow"; +import { + guestReadyState, + guestSendingState, + userSendingViaHttpState, +} from "./send-flow"; +import { + unlockingHistoryState, + unlockingMessageState, + unlockMachineSetup, +} from "./unlock-flow"; + +const startGuestSessionAction = unlockMachineSetup.assign( + ({ context }) => ({ + ...initialState, + promotion: context.promotion, + }), +); + +const startUserSessionAction = unlockMachineSetup.assign( + ({ context }) => ({ + ...initialState, + promotion: context.promotion, + }), +); + +const clearChatSessionAction = unlockMachineSetup.assign(() => ({ + ...initialState, +})); + +const injectPromotionAction = unlockMachineSetup.assign(({ event }) => { + if (event.type !== "ChatPromotionInjected") return {}; + return { + promotion: createChatPromotionState( + event.promotion, + event.messageId, + ), + }; +}); + +const clearPromotionAction = unlockMachineSetup.assign({ promotion: null }); + +export const chatMachineSetup = unlockMachineSetup.extend({ + actions: { + startGuestSession: startGuestSessionAction, + startUserSession: startUserSessionAction, + clearChatSession: clearChatSessionAction, + injectPromotion: injectPromotionAction, + clearPromotion: clearPromotionAction, + }, +}); + +const idleState = chatMachineSetup.createStateConfig({ + on: { + ChatGuestLogin: { + target: "#chat.guestSession", + actions: "startGuestSession", + }, + ChatUserLogin: { + target: "#chat.userSession", + actions: "startUserSession", + }, + }, +}); + +const guestSessionState = chatMachineSetup.createStateConfig({ + invoke: [ + { + id: "messageQueue", + src: "httpMessageQueue", + }, + { + id: "loadHistory", + src: "loadHistory", + }, + ], + on: { + ChatUserLogin: { + target: "#chat.userSession", + actions: "startUserSession", + }, + ChatNetworkHistoryLoaded: { + actions: "applyNetworkHistoryLoaded", + }, + ChatHistoryLoadFailed: { + actions: "markHistoryLoadFailed", + }, + ChatQueuedSendStarted: { + actions: "markQueuedSendStarted", + }, + ChatQueuedHttpDone: { + actions: "applyQueuedHttpOutput", + }, + ChatQueuedSendError: { + actions: "appendQueuedSendErrorMessage", + }, + }, + initial: "initializing", + states: { + initializing: guestInitializingState, + ready: guestReadyState, + sending: guestSendingState, + }, +}); + +const userReadyState = chatMachineSetup.createStateConfig({ + on: { + ChatSendMessage: { + guard: ({ context, event }) => + context.canSendMessage && event.content.trim().length > 0, + actions: ["appendUserMessage", "enqueueMessage"], + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatUnlockMessageRequested: { + guard: ({ event }) => event.messageId.trim().length > 0, + target: "unlockingMessage", + actions: "markUnlockMessageStarted", + }, + }, +}); + +const userSessionState = chatMachineSetup.createStateConfig({ + invoke: [ + { + id: "messageQueue", + src: "httpMessageQueue", + }, + { + id: "loadHistory", + src: "loadHistory", + }, + ], + on: { + ChatGuestLogin: { + target: "#chat.guestSession", + actions: "startGuestSession", + }, + ChatLogout: { + 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", + }, + ChatQueuedSendStarted: { + actions: "markQueuedSendStarted", + }, + ChatQueuedHttpDone: { + actions: "applyQueuedHttpOutput", + }, + ChatQueuedSendError: { + actions: "appendQueuedSendErrorMessage", + }, + ChatPaymentSucceeded: [ + { + guard: ({ context }) => + context.historyLoaded && + shouldPromptUnlockHistory(context.messages), + actions: ["clearUpgradePrompt", "showUnlockHistoryPrompt"], + }, + { + guard: ({ context }) => !context.historyLoaded, + actions: ["clearUpgradePrompt", "markPaymentUnlockPending"], + }, + { + guard: ({ context }) => context.historyLoaded, + actions: ["clearUpgradePrompt", "dismissUnlockHistoryPrompt"], + }, + ], + ChatUnlockHistoryConfirmed: { + target: ".unlockingHistory", + actions: "markUnlockHistoryStarted", + }, + ChatUnlockHistoryDismissed: { + actions: "dismissUnlockHistoryPrompt", + }, + ChatUnlockPaywallNavigationConsumed: { + actions: "clearUnlockPaywallRequest", + }, + }, + initial: "initializing", + states: { + initializing: userInitializingState, + ready: userReadyState, + sendingViaHttp: userSendingViaHttpState, + unlockingHistory: unlockingHistoryState, + unlockingMessage: unlockingMessageState, + }, +}); + +export const chatRootStateConfig = chatMachineSetup.createStateConfig({ + initial: "idle", + on: { + ChatPromotionInjected: { actions: "injectPromotion" }, + ChatPromotionCleared: { actions: "clearPromotion" }, + }, + states: { + idle: idleState, + guestSession: guestSessionState, + userSession: userSessionState, + }, +}); diff --git a/src/stores/chat/machine/setup.ts b/src/stores/chat/machine/setup.ts new file mode 100644 index 00000000..ac27a19c --- /dev/null +++ b/src/stores/chat/machine/setup.ts @@ -0,0 +1,54 @@ +import { setup, type ActionFunction, type EventObject } from "xstate"; + +import type { ChatEvent } from "../chat-events"; +import { + httpMessageQueueActor, + loadHistoryActor, + sendMessageHttpActor, + unlockHistoryActor, + unlockMessageActor, +} from "../chat-machine.actors"; +import type { ChatState } from "../chat-state"; + +export const baseChatMachineSetup = setup({ + types: { + context: {} as ChatState, + events: {} as ChatEvent, + }, + actors: { + loadHistory: loadHistoryActor, + sendMessageHttp: sendMessageHttpActor, + httpMessageQueue: httpMessageQueueActor, + unlockHistory: unlockHistoryActor, + unlockMessage: unlockMessageActor, + }, +}); + +type ChatActorAction = ActionFunction< + ChatState, + TEvent, + ChatEvent, + undefined, + never, + never, + never, + never, + never +>; + +export function createChatActorActionSetup() { + const actorActionSetup = setup({ + types: { + context: {} as ChatState, + events: {} as TEvent, + }, + }); + return { + assign( + assignment: Parameters[0], + ): ChatActorAction { + // Actor lifecycle events are expression events, not public machine events. + return actorActionSetup.assign(assignment) as unknown as ChatActorAction; + }, + }; +} diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts new file mode 100644 index 00000000..9df41336 --- /dev/null +++ b/src/stores/chat/machine/unlock-flow.ts @@ -0,0 +1,253 @@ +import { type DoneActorEvent } from "xstate"; + +import { + applyPromotionUnlockOutput, + applySingleUnlockOutput, + countLockedHistoryMessages, + type UnlockMessageOutput, +} from "../chat-machine.helpers"; +import type { UnlockHistoryOutput } from "../chat-unlock-flow"; +import { sendMachineSetup } from "./send-flow"; +import { createChatActorActionSetup } from "./setup"; + +const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({ + paymentUnlockPending: true, + unlockHistoryError: null, +})); + +const showUnlockHistoryPromptAction = sendMachineSetup.assign( + ({ context }) => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages(context.messages), + unlockHistoryError: null, + }), +); + +const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + unlockHistoryError: null, +})); + +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.", +})); + +const markUnlockMessageStartedAction = sendMachineSetup.assign( + ({ event }) => { + if (event.type !== "ChatUnlockMessageRequested") return {}; + 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, + unlockMessageError: null, + unlockPaywallRequest: null, + }; + }, +); + +const applyUnlockMessageSucceededAction = sendMachineSetup.assign( + ({ context, event }) => { + const { output } = event as unknown as DoneActorEvent; + return { + messages: applySingleUnlockOutput(context.messages, output), + promotion: applyPromotionUnlockOutput(context.promotion, output), + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockingRemoteMessageId: null, + unlockingLockType: null, + unlockingClientLockId: null, + unlockMessageError: null, + unlockPaywallRequest: null, + creditBalance: output.response.creditBalance, + creditsCharged: output.response.creditsCharged, + requiredCredits: 0, + shortfallCredits: 0, + }; + }, +); + +const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign( + ({ context, event }) => { + const { output } = event as unknown as DoneActorEvent; + return { + promotion: applyPromotionUnlockOutput(context.promotion, output), + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockingRemoteMessageId: null, + unlockingLockType: null, + unlockingClientLockId: null, + unlockMessageError: output.response.reason, + unlockPaywallRequest: { + displayMessageId: + output.response.messageId || output.displayMessageId, + ...(output.response.messageId || output.request.messageId + ? { + messageId: + output.response.messageId || output.request.messageId, + } + : {}), + kind: context.unlockingMessageKind ?? "private", + ...(output.request.lockType + ? { lockType: output.request.lockType } + : {}), + ...(output.request.clientLockId + ? { clientLockId: output.request.clientLockId } + : {}), + ...(context.promotion?.message.id === output.displayMessageId + ? { promotion: context.promotion.session } + : {}), + reason: output.response.reason, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }; + }, +); + +const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign( + ({ context }) => ({ + isUnlockingMessage: false, + unlockMessageError: "unlock_failed", + unlockPaywallRequest: + context.unlockingMessageId && context.unlockingMessageKind + ? { + 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 + ? { promotion: context.promotion.session } + : {}), + reason: "unlock_failed", + creditBalance: context.creditBalance, + requiredCredits: context.requiredCredits, + shortfallCredits: context.shortfallCredits, + } + : null, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockingRemoteMessageId: null, + unlockingLockType: null, + unlockingClientLockId: null, + }), +); + +const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({ + unlockPaywallRequest: null, +})); + +export const unlockMachineSetup = sendMachineSetup.extend({ + actions: { + markPaymentUnlockPending: markPaymentUnlockPendingAction, + showUnlockHistoryPrompt: showUnlockHistoryPromptAction, + dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction, + markUnlockHistoryStarted: markUnlockHistoryStartedAction, + markUnlockHistoryFailed: markUnlockHistoryFailedAction, + markUnlockMessageStarted: markUnlockMessageStartedAction, + applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction, + requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction, + requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction, + clearUnlockPaywallRequest: clearUnlockPaywallRequestAction, + }, +}); + +const unlockHistoryDoneActionSetup = + createChatActorActionSetup>(); + +const applyUnlockHistoryOutputAction = + unlockHistoryDoneActionSetup.assign(({ event }) => { + const lockedHistoryCount = countLockedHistoryMessages( + event.output.messages, + ); + const insufficientBalance = + !event.output.unlocked && + event.output.reason === "insufficient_balance"; + return { + messages: event.output.messages, + historyLoaded: true, + paymentUnlockPending: false, + unlockHistoryPromptVisible: insufficientBalance, + lockedHistoryCount, + isUnlockingHistory: false, + unlockHistoryError: insufficientBalance + ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` + : null, + }; + }); + +export const unlockingHistoryState = unlockMachineSetup.createStateConfig({ + invoke: { + id: "unlockHistory", + src: "unlockHistory", + onDone: { + target: "ready", + actions: applyUnlockHistoryOutputAction, + }, + onError: { + target: "ready", + actions: "markUnlockHistoryFailed", + }, + }, +}); + +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 } + : {}), + }), + onDone: [ + { + guard: ({ event }) => event.output.response.unlocked, + target: "ready", + actions: "applyUnlockMessageSucceeded", + }, + { + target: "ready", + actions: "requestUnlockPaymentFromOutput", + }, + ], + onError: { + target: "ready", + actions: "requestUnlockPaymentFromError", + }, + }, +});