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 { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context"; 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"; import { MobileShell } from "@/app/_components/core";
@@ -16,6 +16,7 @@ import {
ChatInputBar, ChatInputBar,
ChatQuotaExhaustedBanner, ChatQuotaExhaustedBanner,
ExternalBrowserDialog, ExternalBrowserDialog,
HistoryUnlockDialog,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
import { import {
@@ -31,6 +32,7 @@ import styles from "./components/chat-screen.module.css";
export function ChatScreen() { export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState(); const authState = useAuthState();
const router = useRouter(); const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
@@ -159,6 +161,15 @@ export function ChatScreen() {
onClose={() => setShowExternalBrowserDialog(false)} onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()} 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> </div>
</MobileShell> </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 "./date-header";
export * from "./external-browser-dialog"; export * from "./external-browser-dialog";
export * from "./fullscreen-image-viewer"; export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog";
export * from "./image-bubble"; export * from "./image-bubble";
export * from "./language-dialog"; export * from "./language-dialog";
export * from "./lottie-message-bubble"; 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 { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
import { ChatAuthSync } from "@/stores/chat/chat-auth-sync"; import { ChatAuthSync } from "@/stores/chat/chat-auth-sync";
import { ChatProvider } from "@/stores/chat/chat-context"; 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 { SidebarProvider } from "@/stores/sidebar/sidebar-context";
import { UserAuthSync } from "@/stores/user/user-auth-sync"; import { UserAuthSync } from "@/stores/user/user-auth-sync";
import { UserProvider } from "@/stores/user/user-context"; import { UserProvider } from "@/stores/user/user-context";
@@ -44,6 +44,7 @@ export function RootProviders({ children }: RootProvidersProps) {
<SidebarProvider> <SidebarProvider>
<ChatProvider> <ChatProvider>
<ChatAuthSync /> <ChatAuthSync />
<PaymentSuccessSync />
{children} {children}
<div id="toast-portal" /> <div id="toast-portal" />
</ChatProvider> </ChatProvider>
@@ -4,6 +4,7 @@ import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import type { ChatState } from "@/stores/chat/chat-state"; import type { ChatState } from "@/stores/chat/chat-state";
import { import {
applyHttpSendOutput, applyHttpSendOutput,
countLockedHistoryMessages,
localMessagesToUi, localMessagesToUi,
sendResponseToUiMessage, sendResponseToUiMessage,
} from "@/stores/chat/chat-machine.helpers"; } from "@/stores/chat/chat-machine.helpers";
@@ -43,6 +44,11 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
hasMore: true, hasMore: true,
historyOffset: 0, historyOffset: 0,
historyLoaded: true, historyLoaded: true,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
isUnlockingHistory: false,
unlockHistoryError: null,
...overrides, ...overrides,
}; };
} }
@@ -231,3 +237,40 @@ describe("localMessagesToUi", () => {
expect(message.imagePaywalled).toBeUndefined(); 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; reply: UiMessage | null;
} }
interface UnlockHistoryOutput {
unlocked: boolean;
reason: string;
shortfallCredits: number;
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
}
function makeChatSendResponse(): ChatSendResponse { function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({ return ChatSendResponse.from({
reply: "", reply: "",
@@ -50,6 +59,7 @@ function makeChatSendResponse(): ChatSendResponse {
function createTestChatMachine( function createTestChatMachine(
options: { options: {
historyMessages?: UiMessage[]; historyMessages?: UiMessage[];
unlockHistoryOutput?: UnlockHistoryOutput;
} = {}, } = {},
) { ) {
return chatMachine.provide({ return chatMachine.provide({
@@ -90,6 +100,15 @@ function createTestChatMachine(
}); });
return () => undefined; 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; return () => undefined;
}), }),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
messages: [],
hasMore: false,
newOffset: 0,
})),
}, },
}); });
const actor = createActor(machine).start(); const actor = createActor(machine).start();
@@ -235,4 +262,96 @@ describe("chatMachine transitions", () => {
actor.stop(); 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; historyOffset: number;
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */ /** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
historyLoaded: boolean; historyLoaded: boolean;
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null;
} }
const ChatStateCtx = createContext<ChatState | null>(null); const ChatStateCtx = createContext<ChatState | null>(null);
@@ -55,6 +59,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
hasMore: state.context.hasMore, hasMore: state.context.hasMore,
historyOffset: state.context.historyOffset, historyOffset: state.context.historyOffset,
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
lockedHistoryCount: state.context.lockedHistoryCount,
isUnlockingHistory: state.context.isUnlockingHistory,
unlockHistoryError: state.context.unlockHistoryError,
}), }),
[state], [state],
); );
+3
View File
@@ -24,6 +24,9 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
| { type: "ChatPaymentSucceeded" }
| { type: "ChatUnlockHistoryConfirmed" }
| { type: "ChatUnlockHistoryDismissed" }
| { type: "ChatQueuedSendStarted" } | { type: "ChatQueuedSendStarted" }
| { | {
type: "ChatQueuedHttpDone"; 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>( export const httpMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => { ({ sendBack, receive }) => {
return createMessageQueueActor(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 { function messageDateFromCreatedAt(createdAt: string): string {
const parsed = new Date(createdAt); const parsed = new Date(createdAt);
if (Number.isNaN(parsed.getTime())) return todayString(); 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( export function normalizeLockDetail(
detail: Record<string, unknown> | null, detail: Record<string, unknown> | null,
): ChatState["upgradeDetail"] { ): ChatState["upgradeDetail"] {
+142 -16
View File
@@ -42,14 +42,19 @@ import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events"; import type { ChatEvent } from "./chat-events";
import { import {
applyHttpSendOutput, applyHttpSendOutput,
applyHistoryLoadedOutput,
beginPendingReply, beginPendingReply,
countLockedHistoryMessages,
finishPendingReply, finishPendingReply,
shouldAutoUnlockHistory,
shouldPromptUnlockHistory,
} from "./chat-machine.helpers"; } from "./chat-machine.helpers";
import { import {
loadHistoryActor, loadHistoryActor,
sendMessageHttpActor, sendMessageHttpActor,
loadMoreHistoryActor, loadMoreHistoryActor,
httpMessageQueueActor, httpMessageQueueActor,
unlockHistoryActor,
} from "./chat-machine.actors"; } from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine"); const log = new Logger("StoresChatChatMachine");
@@ -72,6 +77,7 @@ export const chatMachine = setup({
sendMessageHttp: sendMessageHttpActor, sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor, loadMoreHistory: loadMoreHistoryActor,
httpMessageQueue: httpMessageQueueActor, httpMessageQueue: httpMessageQueueActor,
unlockHistory: unlockHistoryActor,
}, },
actions: { actions: {
enqueueMessage: sendTo("messageQueue", ({ event }) => event), enqueueMessage: sendTo("messageQueue", ({ event }) => event),
@@ -211,6 +217,37 @@ export const chatMachine = setup({
if (event.type !== "ChatQueuedHttpDone") return {}; if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output); 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({ }).createMachine({
id: "chat", id: "chat",
@@ -264,12 +301,9 @@ export const chatMachine = setup({
id: "loadHistory", id: "loadHistory",
src: "loadHistory", src: "loadHistory",
onDone: { onDone: {
actions: assign(({ event }) => ({ actions: assign(({ event }) =>
messages: event.output.messages, applyHistoryLoadedOutput(event.output),
hasMore: event.output.hasMore, ),
historyOffset: event.output.newOffset,
historyLoaded: true,
})),
}, },
onError: { onError: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) // 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
@@ -337,22 +371,80 @@ export const chatMachine = setup({
ChatQueuedSendError: { ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage", 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", initial: "initializing",
states: { states: {
initializing: { initializing: {
invoke: { invoke: {
src: "loadHistory", src: "loadHistory",
onDone: { onDone: [
target: "ready", {
actions: assign(({ event }) => ({ guard: ({ context, event }) =>
messages: event.output.messages, context.paymentUnlockPending &&
isLoadingMore: false, shouldAutoUnlockHistory(event.output.messages),
hasMore: event.output.hasMore, target: "unlockingHistory",
historyOffset: event.output.newOffset, actions: [
historyLoaded: true, assign(({ event }) => ({
})), ...applyHistoryLoadedOutput(event.output),
}, isLoadingMore: false,
})),
"markUnlockHistoryStarted",
],
},
{
guard: ({ context, event }) =>
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: "ready",
actions: assign(({ event }) => ({
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(
event.output.messages,
),
unlockHistoryError: null,
})),
},
{
target: "ready",
actions: assign(({ event }) => ({
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
paymentUnlockPending: false,
})),
},
],
onError: { onError: {
target: "ready", target: "ready",
actions: assign({ 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` 设置(翻页是另一码事) * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
*/ */
historyLoaded: boolean; historyLoaded: boolean;
paymentUnlockPending: boolean;
unlockHistoryPromptVisible: boolean;
lockedHistoryCount: number;
isUnlockingHistory: boolean;
unlockHistoryError: string | null;
} }
export const initialState: ChatState = { export const initialState: ChatState = {
@@ -37,4 +42,9 @@ export const initialState: ChatState = {
hasMore: true, hasMore: true,
historyOffset: 0, historyOffset: 0,
historyLoaded: false, 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-events";
export * from "./payment-machine"; export * from "./payment-machine";
export * from "./payment-machine.actors"; export * from "./payment-machine.actors";
export * from "./payment-success-sync";
export * from "./payment-state"; 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;
}