refactor(chat): consolidate unlock state

This commit is contained in:
2026-07-16 19:49:08 +08:00
parent b5990bca8b
commit acabf165cd
7 changed files with 152 additions and 110 deletions
@@ -80,7 +80,7 @@ export function useChatUnlockCoordinator({
const chatState = useChatSelector( const chatState = useChatSelector(
(state) => ({ (state) => ({
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
isUnlockingHistory: state.context.isUnlockingHistory, isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount, lockedHistoryCount: state.context.lockedHistoryCount,
unlockHistoryError: state.context.unlockHistoryError, unlockHistoryError: state.context.unlockHistoryError,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
@@ -49,14 +49,8 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
paymentUnlockPending: false, paymentUnlockPending: false,
unlockHistoryPromptVisible: false, unlockHistoryPromptVisible: false,
lockedHistoryCount: 0, lockedHistoryCount: 0,
isUnlockingHistory: false,
unlockHistoryError: null, unlockHistoryError: null,
isUnlockingMessage: false, unlockingMessage: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null, unlockMessageError: null,
unlockPaywallRequest: null, unlockPaywallRequest: null,
...overrides, ...overrides,
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate"; import { createActor, fromPromise, waitFor } from "xstate";
import { ChatSendResponse } from "@/data/dto/chat"; import { ChatSendResponse } from "@/data/dto/chat";
import type {
UnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/helper/unlock";
import { import {
createTestChatMachine, createTestChatMachine,
@@ -189,7 +193,6 @@ describe("chat unlock flow", () => {
); );
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true, unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.", 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.unlockPaywallRequest).toBeNull();
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
id: "msg-private-locked", id: "msg-private-locked",
@@ -485,7 +488,7 @@ describe("chat unlock flow", () => {
}, },
]); ]);
expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined(); expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined();
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
actor.stop(); actor.stop();
}); });
@@ -536,8 +539,7 @@ describe("chat unlock flow", () => {
); );
expect(actor.getSnapshot().context).toMatchObject({ expect(actor.getSnapshot().context).toMatchObject({
isUnlockingMessage: false, unlockingMessage: null,
unlockingMessageId: null,
unlockMessageError: "not_found", unlockMessageError: "not_found",
unlockPaywallRequest: null, unlockPaywallRequest: null,
}); });
@@ -552,6 +554,92 @@ describe("chat unlock flow", () => {
actor.stop(); 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 () => { it("clears the insufficient credits prompt after payment succeeds", async () => {
const actor = createActor(createTestChatMachine()).start(); const actor = createActor(createTestChatMachine()).start();
+4 -3
View File
@@ -86,10 +86,11 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
isLoadingMoreHistory: state.context.isLoadingMoreHistory, isLoadingMoreHistory: state.context.isLoadingMoreHistory,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount, lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.context.isUnlockingHistory, isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
unlockHistoryError: state.context.unlockHistoryError, unlockHistoryError: state.context.unlockHistoryError,
isUnlockingMessage: state.context.isUnlockingMessage, isUnlockingMessage: state.matches({ userSession: "unlockingMessage" }),
unlockingMessageId: state.context.unlockingMessageId, unlockingMessageId:
state.context.unlockingMessage?.displayMessageId ?? null,
unlockMessageError: state.context.unlockMessageError, unlockMessageError: state.context.unlockMessageError,
unlockPaywallRequest: state.context.unlockPaywallRequest, unlockPaywallRequest: state.context.unlockPaywallRequest,
}; };
+7 -15
View File
@@ -6,12 +6,16 @@ import type { ChatPromotionState } from "./helper/promotion";
export type ChatUpgradeReason = "insufficient_credits"; export type ChatUpgradeReason = "insufficient_credits";
export interface ChatUnlockPaywallRequest { export interface ChatUnlockMessageRequest {
displayMessageId: string; displayMessageId: string;
messageId?: string; messageId?: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
lockType?: ChatLockType; lockType?: ChatLockType;
clientLockId?: string; clientLockId?: string;
}
export interface ChatUnlockPaywallRequest
extends ChatUnlockMessageRequest {
promotion?: PendingChatPromotion; promotion?: PendingChatPromotion;
reason: string; reason: string;
creditBalance: number; creditBalance: number;
@@ -41,14 +45,8 @@ export interface ChatState {
paymentUnlockPending: boolean; paymentUnlockPending: boolean;
unlockHistoryPromptVisible: boolean; unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number; lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null; unlockHistoryError: string | null;
isUnlockingMessage: boolean; unlockingMessage: ChatUnlockMessageRequest | null;
unlockingMessageId: string | null;
unlockingMessageKind: PendingChatUnlockKind | null;
unlockingRemoteMessageId: string | null;
unlockingLockType: ChatLockType | null;
unlockingClientLockId: string | null;
unlockMessageError: string | null; unlockMessageError: string | null;
unlockPaywallRequest: ChatUnlockPaywallRequest | null; unlockPaywallRequest: ChatUnlockPaywallRequest | null;
} }
@@ -74,14 +72,8 @@ export const initialState: ChatState = {
paymentUnlockPending: false, paymentUnlockPending: false,
unlockHistoryPromptVisible: false, unlockHistoryPromptVisible: false,
lockedHistoryCount: 0, lockedHistoryCount: 0,
isUnlockingHistory: false,
unlockHistoryError: null, unlockHistoryError: null,
isUnlockingMessage: false, unlockingMessage: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null, unlockMessageError: null,
unlockPaywallRequest: null, unlockPaywallRequest: null,
}; };
+16 -20
View File
@@ -117,6 +117,22 @@ const guestSessionState = chatMachineSetup.createStateConfig({
const userReadyState = chatMachineSetup.createStateConfig({ const userReadyState = chatMachineSetup.createStateConfig({
on: { 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: { ChatSendMessage: {
guard: ({ context, event }) => guard: ({ context, event }) =>
context.canSendMessage && event.content.trim().length > 0, context.canSendMessage && event.content.trim().length > 0,
@@ -158,26 +174,6 @@ const userSessionState = chatMachineSetup.createStateConfig({
target: "#chat.idle", target: "#chat.idle",
actions: "clearChatSession", 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: { ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed", actions: "markHistoryLoadFailed",
}, },
+28 -57
View File
@@ -33,12 +33,10 @@ const dismissUnlockHistoryPromptAction = sendMachineSetup.assign(() => ({
const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({ const markUnlockHistoryStartedAction = sendMachineSetup.assign(() => ({
paymentUnlockPending: false, paymentUnlockPending: false,
unlockHistoryPromptVisible: false, unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null, unlockHistoryError: null,
})); }));
const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true, unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.", unlockHistoryError: "Failed to unlock messages. Please try again.",
})); }));
@@ -46,14 +44,18 @@ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
const markUnlockMessageStartedAction = sendMachineSetup.assign( const markUnlockMessageStartedAction = sendMachineSetup.assign(
({ event }) => { ({ event }) => {
if (event.type !== "ChatUnlockMessageRequested") return {}; if (event.type !== "ChatUnlockMessageRequested") return {};
const remoteMessageId =
event.remoteMessageId ?? (event.lockType ? undefined : event.messageId);
return { return {
isUnlockingMessage: true, unlockingMessage: {
unlockingMessageId: event.messageId, displayMessageId: event.messageId,
unlockingMessageKind: event.kind, ...(remoteMessageId ? { messageId: remoteMessageId } : {}),
unlockingRemoteMessageId: kind: event.kind,
event.remoteMessageId ?? (event.lockType ? null : event.messageId), ...(event.lockType ? { lockType: event.lockType } : {}),
unlockingLockType: event.lockType ?? null, ...(event.clientLockId
unlockingClientLockId: event.clientLockId ?? null, ? { clientLockId: event.clientLockId }
: {}),
},
unlockMessageError: null, unlockMessageError: null,
unlockPaywallRequest: null, unlockPaywallRequest: null,
}; };
@@ -66,12 +68,7 @@ const applyUnlockMessageSucceededAction = sendMachineSetup.assign(
return { return {
messages: applySingleUnlockOutput(context.messages, output), messages: applySingleUnlockOutput(context.messages, output),
promotion: applyPromotionUnlockOutput(context.promotion, output), promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false, unlockingMessage: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null, unlockMessageError: null,
unlockPaywallRequest: null, unlockPaywallRequest: null,
creditBalance: output.response.creditBalance, creditBalance: output.response.creditBalance,
@@ -88,12 +85,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
const shouldOpenPaywall = output.response.reason !== "not_found"; const shouldOpenPaywall = output.response.reason !== "not_found";
return { return {
promotion: applyPromotionUnlockOutput(context.promotion, output), promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false, unlockingMessage: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: output.response.reason, unlockMessageError: output.response.reason,
unlockPaywallRequest: shouldOpenPaywall unlockPaywallRequest: shouldOpenPaywall
? { ? {
@@ -105,7 +97,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
output.response.messageId || output.request.messageId, output.response.messageId || output.request.messageId,
} }
: {}), : {}),
kind: context.unlockingMessageKind ?? "private", kind: context.unlockingMessage?.kind ?? "private",
...(output.request.lockType ...(output.request.lockType
? { lockType: output.request.lockType } ? { lockType: output.request.lockType }
: {}), : {}),
@@ -129,24 +121,15 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
); );
const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign( const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
({ context }) => ({ ({ context }) => {
isUnlockingMessage: false, const request = context.unlockingMessage;
return {
unlockingMessage: null,
unlockMessageError: "unlock_failed", unlockMessageError: "unlock_failed",
unlockPaywallRequest: unlockPaywallRequest: request
context.unlockingMessageId && context.unlockingMessageKind
? { ? {
displayMessageId: context.unlockingMessageId, ...request,
...(context.unlockingRemoteMessageId ...(context.promotion?.message.id === request.displayMessageId
? { 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 } ? { promotion: context.promotion.session }
: {}), : {}),
reason: "unlock_failed", reason: "unlock_failed",
@@ -155,12 +138,8 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
shortfallCredits: context.shortfallCredits, shortfallCredits: context.shortfallCredits,
} }
: null, : null,
unlockingMessageId: null, };
unlockingMessageKind: null, },
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
}),
); );
const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({ const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({
@@ -199,7 +178,6 @@ const applyUnlockHistoryOutputAction =
paymentUnlockPending: false, paymentUnlockPending: false,
unlockHistoryPromptVisible: insufficientBalance, unlockHistoryPromptVisible: insufficientBalance,
lockedHistoryCount, lockedHistoryCount,
isUnlockingHistory: false,
unlockHistoryError: insufficientBalance unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null, : null,
@@ -225,18 +203,11 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
invoke: { invoke: {
id: "unlockMessage", id: "unlockMessage",
src: "unlockMessage", src: "unlockMessage",
input: ({ context }) => ({ input: ({ context }) =>
displayMessageId: context.unlockingMessageId ?? "", context.unlockingMessage ?? {
...(context.unlockingRemoteMessageId displayMessageId: "",
? { messageId: context.unlockingRemoteMessageId } kind: "private",
: {}), },
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
}),
onDone: [ onDone: [
{ {
guard: ({ event }) => event.output.response.unlocked, guard: ({ event }) => event.output.response.unlocked,