feat(chat): unlock history after payment
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
+142
-16
@@ -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: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
},
|
||||
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 }) => ({
|
||||
...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: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user