feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
@@ -34,6 +34,7 @@ function makeResponse(
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return {
messages: [],
promotion: null,
isReplyingAI: true,
pendingReplyCount: 1,
upgradePromptVisible: false,
@@ -56,6 +57,9 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null,
unlockPaywallRequest: null,
...overrides,
@@ -8,6 +8,10 @@ import {
} 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 LoadMoreHistoryOutput {
messages: UiMessage[];
@@ -29,7 +33,7 @@ interface UnlockHistoryOutput {
newOffset: number;
}
interface UnlockMessageOutput {
interface TestUnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
@@ -81,7 +85,7 @@ function createTestChatMachine(
options: {
historyMessages?: UiMessage[];
unlockHistoryOutput?: UnlockHistoryOutput;
unlockMessageOutput?: UnlockMessageOutput;
unlockMessageOutput?: TestUnlockMessageOutput;
} = {},
) {
return chatMachine.provide({
@@ -124,13 +128,20 @@ function createTestChatMachine(
newOffset: 0,
...options.unlockHistoryOutput,
})),
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
async ({ input }) =>
options.unlockMessageOutput ?? {
messageId: input.messageId,
response: makeUnlockPrivateResponse(),
},
),
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,
};
}),
},
});
}
@@ -217,6 +228,45 @@ describe("chatMachine transitions", () => {
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<void>((resolve) => {
@@ -586,7 +636,7 @@ describe("chatMachine transitions", () => {
unlockMessageOutput: {
messageId: "msg-private-locked",
response: makeUnlockPrivateResponse({
content: "This response content must be ignored.",
content: "Unlocked private message content.",
}),
},
}),
@@ -612,7 +662,7 @@ describe("chatMachine transitions", () => {
expect(actor.getSnapshot().context.messages).toMatchObject([
{
id: "msg-private-locked",
content: "Original private message content.",
content: "Unlocked private message content.",
locked: false,
lockReason: null,
lockedPrivate: false,
@@ -647,7 +697,7 @@ describe("chatMachine transitions", () => {
unlockMessageOutput: {
messageId: "msg-shared-id",
response: makeUnlockPrivateResponse({
content: "This response content must be ignored.",
content: "Unlocked private AI message.",
}),
},
}),
@@ -676,7 +726,7 @@ describe("chatMachine transitions", () => {
},
{
id: "msg-shared-id",
content: "Original AI private message",
content: "Unlocked private AI message.",
isFromAI: true,
locked: false,
lockReason: null,
@@ -848,6 +898,7 @@ describe("chatMachine transitions", () => {
);
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
displayMessageId: "msg-voice-locked",
messageId: "msg-voice-locked",
kind: "voice",
reason: "insufficient_balance",
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { UnlockPrivateResponse } from "@/data/dto/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import {
appendPromotionMessage,
applyPromotionUnlockOutput,
createChatPromotionState,
} from "@/stores/chat/chat-promotion";
const promotion: PendingChatPromotion = {
promotionType: "image",
lockType: "image_paywall",
clientLockId: "promotion-1",
createdAt: 1,
};
describe("chat promotion", () => {
it("creates the requested locked message and appends it at the tail", () => {
const state = createChatPromotionState(promotion);
const messages = appendPromotionMessage(
[
{
id: "history-1",
content: "History",
isFromAI: true,
date: "2026-07-13",
},
],
state,
);
expect(messages.map((message) => message.id)).toEqual([
"history-1",
"promotion:promotion-1",
]);
expect(messages[1]).toMatchObject({
locked: true,
lockReason: "image_paywall",
imagePaywalled: true,
});
});
it("replaces a temporary promotion with the real unlocked image", () => {
const state = createChatPromotionState(promotion);
const next = applyPromotionUnlockOutput(state, {
displayMessageId: "promotion:promotion-1",
request: {
displayMessageId: "promotion:promotion-1",
lockType: "image_paywall",
clientLockId: "promotion-1",
},
response: UnlockPrivateResponse.from({
unlocked: true,
messageId: "backend-1",
image: {
type: "promotion",
url: "https://example.com/unlocked.jpg",
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
},
}),
});
expect(next?.message).toMatchObject({
id: "backend-1",
imageUrl: "https://example.com/unlocked.jpg",
imagePaywalled: false,
locked: false,
});
});
it("keeps the promotion last and removes matching history duplicates", () => {
const state = createChatPromotionState(promotion, "backend-1");
const messages = appendPromotionMessage(
[
{
id: "backend-1",
content: "Stale history copy",
isFromAI: true,
date: "2026-07-13",
},
],
state,
);
expect(messages).toEqual([state.message]);
});
});