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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user