feat(chat): unlock history after payment

This commit is contained in:
2026-06-29 10:53:52 +08:00
parent 58cd2a7545
commit b7779878cf
15 changed files with 591 additions and 18 deletions
+12 -1
View File
@@ -5,7 +5,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatState } from "@/stores/chat/chat-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { MobileShell } from "@/app/_components/core";
@@ -16,6 +16,7 @@ import {
ChatInputBar,
ChatQuotaExhaustedBanner,
ExternalBrowserDialog,
HistoryUnlockDialog,
PwaInstallOverlay,
} from "./components";
import {
@@ -31,6 +32,7 @@ import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
@@ -159,6 +161,15 @@ export function ChatScreen() {
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
/>
<HistoryUnlockDialog
open={state.unlockHistoryPromptVisible}
lockedCount={state.lockedHistoryCount}
isLoading={state.isUnlockingHistory}
errorMessage={state.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
</div>
</MobileShell>
);
@@ -0,0 +1,80 @@
.overlay {
position: fixed;
inset: 0;
z-index: 75;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: calc(100% - 40px);
max-width: var(--pwa-install-dialog-max-width, 360px);
padding: 24px 16px 16px;
text-align: center;
background: var(--color-page-background, #ffffff);
border-radius: 40px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.title {
margin: 0 0 10px;
font-size: var(--font-size-22, 22px);
font-weight: 700;
line-height: 1.2;
color: var(--color-text-foreground, #171717);
}
.content {
margin: 0 24px var(--spacing-lg, 16px);
font-size: var(--font-size-lg, 16px);
line-height: 1.5;
text-align: left;
color: #393939;
}
.error {
margin: 0 24px var(--spacing-lg, 16px);
font-size: var(--font-size-md, 14px);
line-height: 1.4;
text-align: left;
color: #c0364c;
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
width: 100%;
}
.button {
flex: 1 1 auto;
height: var(--pwa-button-height, 44px);
border: 0;
border-radius: var(--radius-bottom-sheet, 28px);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.secondary {
color: var(--color-page-background, #ffffff);
background: var(--color-text-secondary, #9e9e9e);
}
.primary {
color: var(--color-page-background, #ffffff);
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
}
@@ -0,0 +1,63 @@
"use client";
import styles from "./history-unlock-dialog.module.css";
export interface HistoryUnlockDialogProps {
open: boolean;
lockedCount: number;
isLoading?: boolean;
errorMessage?: string | null;
onClose: () => void;
onConfirm: () => void;
}
export function HistoryUnlockDialog({
open,
lockedCount,
isLoading = false,
errorMessage,
onClose,
onConfirm,
}: HistoryUnlockDialogProps) {
if (!open) return null;
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="history-unlock-title"
>
<div className={styles.dialog}>
<h2 id="history-unlock-title" className={styles.title}>
Unlock previous messages?
</h2>
<p className={styles.content}>
We found {lockedCount} locked messages in your chat history. You can
unlock them now to continue the conversation with full context.
</p>
{errorMessage ? (
<p className={styles.error}>{errorMessage}</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
disabled={isLoading}
>
Later
</button>
<button
type="button"
className={`${styles.button} ${styles.primary}`}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Unlocking..." : "Unlock now"}
</button>
</div>
</div>
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ export * from "./chat-send-button";
export * from "./date-header";
export * from "./external-browser-dialog";
export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog";
export * from "./image-bubble";
export * from "./language-dialog";
export * from "./lottie-message-bubble";
+2 -1
View File
@@ -21,7 +21,7 @@ import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
import { ChatAuthSync } from "@/stores/chat/chat-auth-sync";
import { ChatProvider } from "@/stores/chat/chat-context";
import { PaymentProvider } from "@/stores/payment";
import { PaymentProvider, PaymentSuccessSync } from "@/stores/payment";
import { SidebarProvider } from "@/stores/sidebar/sidebar-context";
import { UserAuthSync } from "@/stores/user/user-auth-sync";
import { UserProvider } from "@/stores/user/user-context";
@@ -44,6 +44,7 @@ export function RootProviders({ children }: RootProvidersProps) {
<SidebarProvider>
<ChatProvider>
<ChatAuthSync />
<PaymentSuccessSync />
{children}
<div id="toast-portal" />
</ChatProvider>
@@ -4,6 +4,7 @@ import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import type { ChatState } from "@/stores/chat/chat-state";
import {
applyHttpSendOutput,
countLockedHistoryMessages,
localMessagesToUi,
sendResponseToUiMessage,
} from "@/stores/chat/chat-machine.helpers";
@@ -43,6 +44,11 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
hasMore: true,
historyOffset: 0,
historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
isUnlockingHistory: false,
unlockHistoryError: null,
...overrides,
};
}
@@ -231,3 +237,40 @@ describe("localMessagesToUi", () => {
expect(message.imagePaywalled).toBeUndefined();
});
});
describe("countLockedHistoryMessages", () => {
it("counts only unlockable locked AI messages", () => {
expect(
countLockedHistoryMessages([
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "private_message",
},
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "voice_message",
},
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "daily_limit",
},
{
content: "user text",
isFromAI: false,
date: "2026-06-29",
locked: true,
lockReason: "private_message",
},
]),
).toBe(2);
});
});
@@ -28,6 +28,15 @@ interface SendMessageHttpOutput {
reply: UiMessage | null;
}
interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
}
function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
@@ -50,6 +59,7 @@ function makeChatSendResponse(): ChatSendResponse {
function createTestChatMachine(
options: {
historyMessages?: UiMessage[];
unlockHistoryOutput?: UnlockHistoryOutput;
} = {},
) {
return chatMachine.provide({
@@ -90,6 +100,15 @@ function createTestChatMachine(
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
hasMore: false,
newOffset: 0,
...options.unlockHistoryOutput,
})),
},
});
}
@@ -213,6 +232,14 @@ describe("chatMachine transitions", () => {
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
hasMore: false,
newOffset: 0,
})),
},
});
const actor = createActor(machine).start();
@@ -235,4 +262,96 @@ describe("chatMachine transitions", () => {
actor.stop();
});
it("prompts before unlocking multiple locked history messages after payment", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "private_message",
},
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "voice_message",
},
],
}),
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatPaymentSucceeded" });
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(true);
expect(actor.getSnapshot().context.lockedHistoryCount).toBe(2);
actor.stop();
});
it("unlocks history after the prompt is confirmed", async () => {
const actor = createActor(
createTestChatMachine({
historyMessages: [
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "private_message",
},
{
content: "",
isFromAI: true,
date: "2026-06-29",
locked: true,
lockReason: "voice_message",
},
],
unlockHistoryOutput: {
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [
{
content: "unlocked",
isFromAI: true,
date: "2026-06-29",
locked: false,
lockReason: null,
},
],
hasMore: false,
newOffset: 1,
},
}),
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatPaymentSucceeded" });
actor.send({ type: "ChatUnlockHistoryConfirmed" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "unlocked", locked: false },
]);
actor.stop();
});
});
+8
View File
@@ -30,6 +30,10 @@ interface ChatState {
historyOffset: number;
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
historyLoaded: boolean;
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null;
}
const ChatStateCtx = createContext<ChatState | null>(null);
@@ -55,6 +59,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
hasMore: state.context.hasMore,
historyOffset: state.context.historyOffset,
historyLoaded: state.context.historyLoaded,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.context.isUnlockingHistory,
unlockHistoryError: state.context.unlockHistoryError,
}),
[state],
);
+3
View File
@@ -24,6 +24,9 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" }
| { type: "ChatPaymentSucceeded" }
| { type: "ChatUnlockHistoryConfirmed" }
| { type: "ChatUnlockHistoryDismissed" }
| { type: "ChatQueuedSendStarted" }
| {
type: "ChatQueuedHttpDone";
+27
View File
@@ -73,6 +73,33 @@ export const loadMoreHistoryActor = fromPromise<
};
});
export const unlockHistoryActor = fromPromise<{
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
}>(async () => {
const unlockResult = await chatRepo.unlockHistory();
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error,
});
throw unlockResult.error;
}
const history = await readAndSyncHistory();
return {
unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason,
shortfallCredits: unlockResult.data.shortfallCredits,
messages: history.messages,
hasMore: history.hasMore,
newOffset: history.newOffset,
};
});
export const httpMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => {
return createMessageQueueActor(sendBack, receive);
+39
View File
@@ -62,6 +62,19 @@ export function localMessagesToUi(
}));
}
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
return messages.filter(isUnlockableLockedMessage).length;
}
function isUnlockableLockedMessage(message: UiMessage): boolean {
if (!message.isFromAI || message.locked !== true) return false;
if (message.imagePaywalled === true) return true;
return (
message.lockReason === "private_message" ||
message.lockReason === "voice_message"
);
}
function messageDateFromCreatedAt(createdAt: string): string {
const parsed = new Date(createdAt);
if (Number.isNaN(parsed.getTime())) return todayString();
@@ -213,6 +226,32 @@ export function applyHttpSendOutput(
};
}
export function applyHistoryLoadedOutput(
output: {
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
},
): Pick<
ChatState,
"messages" | "hasMore" | "historyOffset" | "historyLoaded"
> {
return {
messages: output.messages,
hasMore: output.hasMore,
historyOffset: output.newOffset,
historyLoaded: true,
};
}
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) === 1;
}
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) > 1;
}
export function normalizeLockDetail(
detail: Record<string, unknown> | null,
): ChatState["upgradeDetail"] {
+137 -11
View File
@@ -42,14 +42,19 @@ import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
applyHttpSendOutput,
applyHistoryLoadedOutput,
beginPendingReply,
countLockedHistoryMessages,
finishPendingReply,
shouldAutoUnlockHistory,
shouldPromptUnlockHistory,
} from "./chat-machine.helpers";
import {
loadHistoryActor,
sendMessageHttpActor,
loadMoreHistoryActor,
httpMessageQueueActor,
unlockHistoryActor,
} from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine");
@@ -72,6 +77,7 @@ export const chatMachine = setup({
sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor,
httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
},
actions: {
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
@@ -211,6 +217,37 @@ export const chatMachine = setup({
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
}),
markPaymentUnlockPending: assign(() => ({
paymentUnlockPending: true,
unlockHistoryError: null,
})),
showUnlockHistoryPrompt: assign(({ context }) => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(context.messages),
unlockHistoryError: null,
})),
dismissUnlockHistoryPrompt: assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
unlockHistoryError: null,
})),
markUnlockHistoryStarted: assign(() => ({
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
isUnlockingHistory: true,
unlockHistoryError: null,
})),
markUnlockHistoryFailed: assign(() => ({
isUnlockingHistory: false,
unlockHistoryPromptVisible: true,
unlockHistoryError: "Failed to unlock messages. Please try again.",
})),
},
}).createMachine({
id: "chat",
@@ -264,12 +301,9 @@ export const chatMachine = setup({
id: "loadHistory",
src: "loadHistory",
onDone: {
actions: assign(({ event }) => ({
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
})),
actions: assign(({ event }) =>
applyHistoryLoadedOutput(event.output),
),
},
onError: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
@@ -337,22 +371,80 @@ export const chatMachine = setup({
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
ChatPaymentSucceeded: [
{
guard: ({ context }) =>
context.historyLoaded && shouldAutoUnlockHistory(context.messages),
target: ".unlockingHistory",
actions: "markUnlockHistoryStarted",
},
{
guard: ({ context }) =>
context.historyLoaded &&
shouldPromptUnlockHistory(context.messages),
actions: "showUnlockHistoryPrompt",
},
{
guard: ({ context }) => !context.historyLoaded,
actions: "markPaymentUnlockPending",
},
{
guard: ({ context }) => context.historyLoaded,
actions: "dismissUnlockHistoryPrompt",
},
],
ChatUnlockHistoryConfirmed: {
target: ".unlockingHistory",
actions: "markUnlockHistoryStarted",
},
ChatUnlockHistoryDismissed: {
actions: "dismissUnlockHistoryPrompt",
},
},
initial: "initializing",
states: {
initializing: {
invoke: {
src: "loadHistory",
onDone: {
onDone: [
{
guard: ({ context, event }) =>
context.paymentUnlockPending &&
shouldAutoUnlockHistory(event.output.messages),
target: "unlockingHistory",
actions: [
assign(({ event }) => ({
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
})),
"markUnlockHistoryStarted",
],
},
{
guard: ({ context, event }) =>
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: "ready",
actions: assign(({ event }) => ({
messages: event.output.messages,
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(
event.output.messages,
),
unlockHistoryError: null,
})),
},
{
target: "ready",
actions: assign(({ event }) => ({
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
paymentUnlockPending: false,
})),
},
],
onError: {
target: "ready",
actions: assign({
@@ -413,6 +505,40 @@ export const chatMachine = setup({
},
},
},
unlockingHistory: {
invoke: {
id: "unlockHistory",
src: "unlockHistory",
onDone: {
target: "ready",
actions: assign(({ event }) => {
const lockedHistoryCount = countLockedHistoryMessages(
event.output.messages,
);
const insufficientBalance =
!event.output.unlocked &&
event.output.reason === "insufficient_balance";
return {
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: insufficientBalance,
lockedHistoryCount,
isUnlockingHistory: false,
unlockHistoryError: insufficientBalance
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
: null,
};
}),
},
onError: {
target: "ready",
actions: "markUnlockHistoryFailed",
},
},
},
},
},
},
+10
View File
@@ -23,6 +23,11 @@ export interface ChatState {
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
*/
historyLoaded: boolean;
paymentUnlockPending: boolean;
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null;
}
export const initialState: ChatState = {
@@ -37,4 +42,9 @@ export const initialState: ChatState = {
hasMore: true,
historyOffset: 0,
historyLoaded: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
isUnlockingHistory: false,
unlockHistoryError: null,
};
+1
View File
@@ -6,4 +6,5 @@ export * from "./payment-context";
export * from "./payment-events";
export * from "./payment-machine";
export * from "./payment-machine.actors";
export * from "./payment-success-sync";
export * from "./payment-state";
@@ -0,0 +1,41 @@
"use client";
/**
* Payment → User / Chat 同步器
*
* 支付成功是跨模块事件:
* - User 需要重新拉取 profile + entitlements,刷新 isVip / creditBalance。
* - Chat 需要根据历史锁定消息数量决定直接解锁或弹窗确认。
*/
import { useEffect, useRef } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { usePaymentState } from "./payment-context";
export function PaymentSuccessSync() {
const paymentState = usePaymentState();
const userDispatch = useUserDispatch();
const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!paymentState.isPaid || !paymentState.currentOrderId) return;
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey;
userDispatch({ type: "UserFetch" });
chatDispatch({ type: "ChatPaymentSucceeded" });
}, [
chatDispatch,
paymentState.currentOrderId,
paymentState.isPaid,
paymentState.orderStatus,
userDispatch,
]);
return null;
}