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]);
});
});
+9 -1
View File
@@ -11,6 +11,7 @@ import { useMachine } from "@xstate/react";
import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./chat-promotion";
/**
* 对外暴露的 State 形状
@@ -20,6 +21,8 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
*/
interface ChatState {
messages: MachineContext["messages"];
historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"];
isReplyingAI: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
@@ -51,7 +54,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
// 映射 XState 状态机快照 → 原 ChatState 形状
const chatState = useMemo<ChatState>(
() => ({
messages: state.context.messages,
messages: appendPromotionMessage(
state.context.messages,
state.context.promotion,
),
historyMessages: state.context.messages,
promotion: state.context.promotion,
isReplyingAI: state.context.isReplyingAI,
upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason,
+11
View File
@@ -16,6 +16,8 @@
* chat-screen 不直接读 AuthStorage(除 token 取值)。
*/
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
export type ChatEvent =
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
@@ -35,10 +37,19 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" }
| {
type: "ChatPromotionInjected";
promotion: PendingChatPromotion;
messageId?: string;
}
| { type: "ChatPromotionCleared" }
| {
type: "ChatUnlockMessageRequested";
messageId: string;
remoteMessageId?: string;
kind: PendingChatUnlockKind;
lockType?: ChatLockType;
clientLockId?: string;
}
| { type: "ChatUnlockPaywallNavigationConsumed" }
| { type: "ChatPaymentSucceeded" }
+1
View File
@@ -6,6 +6,7 @@ import type { ChatState } from "./chat-state";
export const PAGE_SIZE = 50;
export * from "./chat-message-mappers";
export * from "./chat-promotion";
export * from "./chat-send-state";
export * from "./chat-unlock-helpers";
+31 -3
View File
@@ -37,6 +37,7 @@ import {
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages,
shouldPromptUnlockHistory,
createChatPromotionState,
} from "./chat-machine.helpers";
import {
loadHistoryActor,
@@ -113,18 +114,32 @@ export const chatMachine = setup({
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
startGuestSession: assign(() => ({
startGuestSession: assign(({ context }) => ({
...initialState,
promotion: context.promotion,
})),
startUserSession: assign(() => ({
startUserSession: assign(({ context }) => ({
...initialState,
promotion: context.promotion,
})),
clearChatSession: assign(() => ({
...initialState,
})),
injectPromotion: assign(({ event }) => {
if (event.type !== "ChatPromotionInjected") return {};
return {
promotion: createChatPromotionState(
event.promotion,
event.messageId,
),
};
}),
clearPromotion: assign({ promotion: null }),
applyLocalHistoryLoaded: assign(({ event }) => {
if (event.type !== "ChatLocalHistoryLoaded") return {};
return applyHistoryLoadedOutput(event.output);
@@ -169,6 +184,10 @@ export const chatMachine = setup({
id: "chat",
initial: "idle",
context: initialState,
on: {
ChatPromotionInjected: { actions: "injectPromotion" },
ChatPromotionCleared: { actions: "clearPromotion" },
},
states: {
idle: {
on: {
@@ -471,7 +490,16 @@ export const chatMachine = setup({
id: "unlockMessage",
src: "unlockMessage",
input: ({ context }) => ({
messageId: context.unlockingMessageId ?? "",
displayMessageId: context.unlockingMessageId ?? "",
...(context.unlockingRemoteMessageId
? { messageId: context.unlockingRemoteMessageId }
: {}),
...(context.unlockingLockType
? { lockType: context.unlockingLockType }
: {}),
...(context.unlockingClientLockId
? { clientLockId: context.unlockingClientLockId }
: {}),
}),
onDone: [
{
+68
View File
@@ -0,0 +1,68 @@
import type { UiMessage } from "@/data/dto/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import { todayString } from "@/utils";
import {
applySingleUnlockOutput,
type UnlockMessageOutput,
} from "./chat-unlock-helpers";
const PROMOTION_COPY = {
voice: "I left a voice message for you. Unlock it to listen.",
image: "I sent you a private photo. Unlock it to view.",
private: "I have something private to tell you. Unlock it to read.",
} as const;
export interface ChatPromotionState {
session: PendingChatPromotion;
message: UiMessage;
}
export function createChatPromotionState(
session: PendingChatPromotion,
messageId?: string,
): ChatPromotionState {
const isImage = session.promotionType === "image";
const isPrivate = session.promotionType === "private";
return {
session,
message: {
id: messageId || `promotion:${session.clientLockId}`,
content: "",
isFromAI: true,
date: todayString(),
locked: true,
lockReason: session.lockType,
imagePaywalled: isImage ? true : undefined,
lockedPrivate: isPrivate ? true : undefined,
privateMessageHint: PROMOTION_COPY[session.promotionType],
},
};
}
export function appendPromotionMessage(
messages: readonly UiMessage[],
promotion: ChatPromotionState | null,
): UiMessage[] {
if (!promotion) return [...messages];
return [
...messages.filter((message) => message.id !== promotion.message.id),
promotion.message,
];
}
export function applyPromotionUnlockOutput(
promotion: ChatPromotionState | null,
output: UnlockMessageOutput,
): ChatPromotionState | null {
if (!promotion || promotion.message.id !== output.displayMessageId) {
return promotion;
}
return {
...promotion,
message:
applySingleUnlockOutput([promotion.message], output)[0] ??
promotion.message,
};
}
+16 -1
View File
@@ -1,11 +1,18 @@
import type { UiMessage } from "@/data/dto/chat";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import type { ChatPromotionState } from "./chat-promotion";
export type ChatUpgradeReason = "insufficient_credits";
export interface ChatUnlockPaywallRequest {
messageId: string;
displayMessageId: string;
messageId?: string;
kind: PendingChatUnlockKind;
lockType?: ChatLockType;
clientLockId?: string;
promotion?: PendingChatPromotion;
reason: string;
creditBalance: number;
requiredCredits: number;
@@ -14,6 +21,7 @@ export interface ChatUnlockPaywallRequest {
export interface ChatState {
messages: UiMessage[];
promotion: ChatPromotionState | null;
isReplyingAI: boolean;
pendingReplyCount: number;
upgradePromptVisible: boolean;
@@ -40,12 +48,16 @@ export interface ChatState {
isUnlockingMessage: boolean;
unlockingMessageId: string | null;
unlockingMessageKind: PendingChatUnlockKind | null;
unlockingRemoteMessageId: string | null;
unlockingLockType: ChatLockType | null;
unlockingClientLockId: string | null;
unlockMessageError: string | null;
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
}
export const initialState: ChatState = {
messages: [],
promotion: null,
isReplyingAI: false,
pendingReplyCount: 0,
upgradePromptVisible: false,
@@ -68,6 +80,9 @@ export const initialState: ChatState = {
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
+60 -6
View File
@@ -11,7 +11,9 @@ import {
import { readAndSyncHistory } from "./chat-history-sync";
import {
applySingleUnlockOutput,
applyPromotionUnlockOutput,
countLockedHistoryMessages,
type UnlockMessageRequest,
type UnlockMessageOutput,
} from "./chat-machine.helpers";
@@ -49,24 +51,32 @@ export const unlockHistoryActor = fromPromise<{
export const unlockMessageActor = fromPromise<
UnlockMessageOutput,
{ messageId: string }
UnlockMessageRequest
>(async ({ input }) => {
const chatRepo = getChatRepository();
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
const unlockResult = await chatRepo.unlockPrivateMessage({
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.lockType ? { lockType: input.lockType } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
});
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockMessageActor failed", {
messageId: input.messageId,
clientLockId: input.clientLockId,
error: unlockResult.error,
});
throw unlockResult.error;
}
if (unlockResult.data.unlocked) {
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId,
{
content: unlockResult.data.content,
audioUrl: unlockResult.data.audioUrl,
...(unlockResult.data.image.url
? { image: unlockResult.data.image }
: {}),
lockDetail: unlockResult.data.lockDetail,
},
);
@@ -79,7 +89,8 @@ export const unlockMessageActor = fromPromise<
}
return {
messageId: input.messageId,
displayMessageId: input.displayMessageId,
request: input,
response: unlockResult.data,
};
});
@@ -124,6 +135,10 @@ export const markUnlockMessageStartedAction = chatAssign(
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,
};
@@ -136,9 +151,13 @@ export const applyUnlockMessageSucceededAction = chatAssign(
if (!output) return {};
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,
@@ -154,13 +173,33 @@ export const requestUnlockPaymentFromOutputAction = chatAssign(
const output = (event as { output?: UnlockMessageOutput }).output;
if (!output) return {};
return {
promotion: applyPromotionUnlockOutput(context.promotion, output),
isUnlockingMessage: false,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
unlockMessageError: output.response.reason,
unlockPaywallRequest: {
messageId: output.messageId,
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,
@@ -180,8 +219,20 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
unlockPaywallRequest:
context.unlockingMessageId && context.unlockingMessageKind
? {
messageId: context.unlockingMessageId,
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,
@@ -190,6 +241,9 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
: null,
unlockingMessageId: null,
unlockingMessageKind: null,
unlockingRemoteMessageId: null,
unlockingLockType: null,
unlockingClientLockId: null,
}),
);
+36 -5
View File
@@ -1,7 +1,16 @@
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
import type { ChatLockType } from "@/data/schemas/chat";
export interface UnlockMessageRequest {
displayMessageId: string;
messageId?: string;
lockType?: ChatLockType;
clientLockId?: string;
}
export interface UnlockMessageOutput {
messageId: string;
displayMessageId: string;
request: UnlockMessageRequest;
response: UnlockPrivateResponse;
}
@@ -13,17 +22,35 @@ export function applySingleUnlockOutput(
messages: readonly UiMessage[],
output: UnlockMessageOutput,
): UiMessage[] {
if (!output.response.unlocked) return [...messages];
return messages.map((message) => {
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
if (!shouldApplySingleUnlock(message, output.displayMessageId)) {
return message;
}
const resolvedId = output.response.messageId || message.id;
if (!output.response.unlocked) {
return {
...message,
id: resolvedId,
};
}
const resolvedImageUrl =
output.response.image.url ?? message.imageUrl;
const resolvedContent =
message.lockReason === "private_message"
? output.response.content || message.content
: message.content;
return {
...message,
id: resolvedId,
content: resolvedContent,
audioUrl: getUnlockedAudioUrl(message, output.response),
imageUrl: resolvedImageUrl,
locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason,
imagePaywalled: message.imageUrl
imagePaywalled: resolvedImageUrl
? output.response.lockDetail.locked &&
output.response.lockDetail.showUpgrade
: undefined,
@@ -48,6 +75,8 @@ function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean
if (message.locked !== true) return false;
return (
message.imagePaywalled === true ||
message.lockReason === "image_paywall" ||
message.lockReason === "image" ||
message.lockReason === "private_message" ||
message.lockReason === "voice_message"
);
@@ -61,6 +90,8 @@ function isUnlockableLockedMessage(message: UiMessage): boolean {
if (!message.isFromAI || message.locked !== true) return false;
if (message.imagePaywalled === true) return true;
return (
message.lockReason === "image_paywall" ||
message.lockReason === "image" ||
message.lockReason === "private_message" ||
message.lockReason === "voice_message"
);