refactor(chat): consolidate unlock state
This commit is contained in:
@@ -49,14 +49,8 @@ function makeChatState(overrides: Partial<ChatState> = {}): 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,
|
||||
|
||||
@@ -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<UnlockMessageOutput>((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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user