refactor(chat): remove private unlock machine flow
This commit is contained in:
@@ -49,12 +49,6 @@ export function ChatScreen() {
|
|||||||
state.upgradePromptVisible && state.upgradeReason === "daily_limit";
|
state.upgradePromptVisible && state.upgradeReason === "daily_limit";
|
||||||
const messageLimitTitle = "The limit for free chat times\nhas been reached";
|
const messageLimitTitle = "The limit for free chat times\nhas been reached";
|
||||||
|
|
||||||
const showPrivatePaywallBanner =
|
|
||||||
state.upgradePromptVisible && state.upgradeReason === "private_message";
|
|
||||||
|
|
||||||
const inlineUpgradeTitle = "Unlock VIP to view private messages";
|
|
||||||
const inlineUpgradeCta = "Activate VIP to view private messages";
|
|
||||||
|
|
||||||
const externalBrowserPromptShownRef = useRef(false);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
const chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
|
const chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
|
||||||
returnTo: "chat",
|
returnTo: "chat",
|
||||||
@@ -129,7 +123,7 @@ export function ChatScreen() {
|
|||||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(): void {
|
function openChatPaywallSubscription(): void {
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
||||||
return;
|
return;
|
||||||
@@ -137,28 +131,20 @@ export function ChatScreen() {
|
|||||||
router.push(chatPaywallSubscriptionUrl);
|
router.push(chatPaywallSubscriptionUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUnlockPrivateMessage(): void {
|
||||||
|
openChatPaywallSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
function handleUnlockImagePaywall(): void {
|
function handleUnlockImagePaywall(): void {
|
||||||
if (isGuest) {
|
openChatPaywallSubscription();
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(chatPaywallSubscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageLimitUnlock(): void {
|
function handleMessageLimitUnlock(): void {
|
||||||
if (isGuest) {
|
openChatPaywallSubscription();
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(chatPaywallSubscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockVoiceMessage(): void {
|
function handleUnlockVoiceMessage(): void {
|
||||||
if (isGuest) {
|
openChatPaywallSubscription();
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(chatPaywallSubscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,19 +169,11 @@ export function ChatScreen() {
|
|||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showPrivatePaywallBanner ? (
|
|
||||||
<ChatQuotaExhaustedBanner
|
|
||||||
title={inlineUpgradeTitle}
|
|
||||||
ctaLabel={inlineUpgradeCta}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showMessageLimitBanner ? (
|
{showMessageLimitBanner ? (
|
||||||
<ChatQuotaExhaustedBanner
|
<ChatQuotaExhaustedBanner
|
||||||
title={messageLimitTitle}
|
title={messageLimitTitle}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ export interface ChatAreaProps {
|
|||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
unlockingPrivateMessageId?: string | null;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
@@ -35,7 +34,6 @@ export interface ChatAreaProps {
|
|||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
messages,
|
messages,
|
||||||
isReplyingAI,
|
isReplyingAI,
|
||||||
unlockingPrivateMessageId,
|
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
@@ -60,7 +58,6 @@ export function ChatArea({
|
|||||||
|
|
||||||
{renderMessagesWithDateHeaders(
|
{renderMessagesWithDateHeaders(
|
||||||
messages,
|
messages,
|
||||||
unlockingPrivateMessageId,
|
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
@@ -74,8 +71,7 @@ export function ChatArea({
|
|||||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||||
function renderMessagesWithDateHeaders(
|
function renderMessagesWithDateHeaders(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
unlockingPrivateMessageId?: string | null,
|
onUnlockPrivateMessage?: () => void,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
|
||||||
onUnlockImagePaywall?: () => void,
|
onUnlockImagePaywall?: () => void,
|
||||||
onUnlockVoiceMessage?: () => void,
|
onUnlockVoiceMessage?: () => void,
|
||||||
) {
|
) {
|
||||||
@@ -94,7 +90,6 @@ function renderMessagesWithDateHeaders(
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={item.key}
|
key={item.key}
|
||||||
id={item.message.id}
|
|
||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
imageUrl={item.message.imageUrl}
|
imageUrl={item.message.imageUrl}
|
||||||
imagePaywalled={item.message.imagePaywalled}
|
imagePaywalled={item.message.imagePaywalled}
|
||||||
@@ -104,10 +99,6 @@ function renderMessagesWithDateHeaders(
|
|||||||
lockReason={item.message.lockReason}
|
lockReason={item.message.lockReason}
|
||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
isUnlockingPrivate={
|
|
||||||
item.message.id != null &&
|
|
||||||
item.message.id === unlockingPrivateMessageId
|
|
||||||
}
|
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { MessageContent } from "./message-content";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
id?: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -25,14 +24,12 @@ export interface MessageBubbleProps {
|
|||||||
lockReason?: string | null;
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
isUnlockingPrivate?: boolean;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
id,
|
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
@@ -42,7 +39,6 @@ export function MessageBubble({
|
|||||||
lockReason,
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
isUnlockingPrivate,
|
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
@@ -55,7 +51,6 @@ export function MessageBubble({
|
|||||||
<MessageAvatar isFromAI={true} />
|
<MessageAvatar isFromAI={true} />
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
messageId={id}
|
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
@@ -65,7 +60,6 @@ export function MessageBubble({
|
|||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
isUnlockingPrivate={isUnlockingPrivate}
|
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
@@ -79,7 +73,6 @@ export function MessageBubble({
|
|||||||
<div className={styles.bubbleRowUser} aria-label="User message">
|
<div className={styles.bubbleRowUser} aria-label="User message">
|
||||||
<div style={{ width: 43 }} /> {/* 占位 */}
|
<div style={{ width: 43 }} /> {/* 占位 */}
|
||||||
<MessageContent
|
<MessageContent
|
||||||
messageId={id}
|
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
@@ -89,7 +82,6 @@ export function MessageBubble({
|
|||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
isUnlockingPrivate={isUnlockingPrivate}
|
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { TextBubble } from "./text-bubble";
|
|||||||
import { VoiceBubble } from "./voice-bubble";
|
import { VoiceBubble } from "./voice-bubble";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
messageId?: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -15,8 +14,7 @@ export interface MessageContentProps {
|
|||||||
lockReason?: string | null;
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
isUnlockingPrivate?: boolean;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
@@ -24,7 +22,6 @@ export interface MessageContentProps {
|
|||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
const IMAGE_PLACEHOLDER = "[图片]";
|
||||||
|
|
||||||
export function MessageContent({
|
export function MessageContent({
|
||||||
messageId,
|
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
@@ -34,7 +31,6 @@ export function MessageContent({
|
|||||||
lockReason,
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
isUnlockingPrivate = false,
|
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
@@ -63,12 +59,8 @@ export function MessageContent({
|
|||||||
{isLockedPrivateMessage ? (
|
{isLockedPrivateMessage ? (
|
||||||
<PrivateMessageCard
|
<PrivateMessageCard
|
||||||
hint={privateMessageHint}
|
hint={privateMessageHint}
|
||||||
isUnlocking={isUnlockingPrivate}
|
isUnlocking={false}
|
||||||
onUnlock={
|
onUnlock={onUnlockPrivateMessage}
|
||||||
messageId
|
|
||||||
? () => onUnlockPrivateMessage?.(messageId)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -85,7 +77,7 @@ export function MessageContent({
|
|||||||
isFromAI={isFromAI}
|
isFromAI={isFromAI}
|
||||||
locked={isLockedVoiceMessage}
|
locked={isLockedVoiceMessage}
|
||||||
hint={privateMessageHint}
|
hint={privateMessageHint}
|
||||||
isUnlocking={isUnlockingPrivate}
|
isUnlocking={false}
|
||||||
onUnlock={
|
onUnlock={
|
||||||
isLockedVoiceMessage
|
isLockedVoiceMessage
|
||||||
? onUnlockVoiceMessage
|
? onUnlockVoiceMessage
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
upgradeDetail: null,
|
upgradeDetail: null,
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createActor, fromCallback, fromPromise, waitFor } from "xstate";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
UnlockPrivateResponse,
|
|
||||||
type UiMessage,
|
type UiMessage,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||||
@@ -29,11 +28,6 @@ interface SendMessageHttpOutput {
|
|||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnlockPrivateOutput {
|
|
||||||
messageId: string;
|
|
||||||
response: UnlockPrivateResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeChatSendResponse(): ChatSendResponse {
|
function makeChatSendResponse(): ChatSendResponse {
|
||||||
return ChatSendResponse.from({
|
return ChatSendResponse.from({
|
||||||
reply: "",
|
reply: "",
|
||||||
@@ -56,7 +50,6 @@ function makeChatSendResponse(): ChatSendResponse {
|
|||||||
function createTestChatMachine(
|
function createTestChatMachine(
|
||||||
options: {
|
options: {
|
||||||
historyMessages?: UiMessage[];
|
historyMessages?: UiMessage[];
|
||||||
unlockedContent?: string;
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return chatMachine.provide({
|
return chatMachine.provide({
|
||||||
@@ -97,21 +90,6 @@ function createTestChatMachine(
|
|||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
}),
|
||||||
unlockPrivateMessage: fromPromise<
|
|
||||||
UnlockPrivateOutput,
|
|
||||||
{ messageId: string }
|
|
||||||
>(async ({ input }) => ({
|
|
||||||
messageId: input.messageId,
|
|
||||||
response: UnlockPrivateResponse.from({
|
|
||||||
unlocked: true,
|
|
||||||
content: options.unlockedContent ?? "unlocked",
|
|
||||||
showUpgrade: false,
|
|
||||||
paywallTriggered: false,
|
|
||||||
privateFreeLimit: 0,
|
|
||||||
privateUsedToday: 0,
|
|
||||||
reason: "ok",
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -167,49 +145,6 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlocks a locked private message and clears unlocking state", async () => {
|
|
||||||
const actor = createActor(
|
|
||||||
createTestChatMachine({
|
|
||||||
historyMessages: [
|
|
||||||
{
|
|
||||||
id: "private-1",
|
|
||||||
content: "",
|
|
||||||
isFromAI: true,
|
|
||||||
date: "2026-06-25",
|
|
||||||
locked: true,
|
|
||||||
lockReason: "private_message",
|
|
||||||
isPrivate: true,
|
|
||||||
lockedPrivate: true,
|
|
||||||
privateMessageHint: "A private message is waiting.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
unlockedContent: "Here is the unlocked private message.",
|
|
||||||
}),
|
|
||||||
).start();
|
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
|
||||||
await waitFor(actor, (snapshot) =>
|
|
||||||
snapshot.matches({ userSession: "ready" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" });
|
|
||||||
await waitFor(
|
|
||||||
actor,
|
|
||||||
(snapshot) =>
|
|
||||||
snapshot.context.messages[0]?.content ===
|
|
||||||
"Here is the unlocked private message.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const [message] = actor.getSnapshot().context.messages;
|
|
||||||
expect(message.content).toBe("Here is the unlocked private message.");
|
|
||||||
expect(message.locked).toBe(false);
|
|
||||||
expect(message.lockedPrivate).toBe(false);
|
|
||||||
expect(message.privateMessageHint).toBeNull();
|
|
||||||
expect(actor.getSnapshot().context.unlockingPrivateMessageId).toBeNull();
|
|
||||||
|
|
||||||
actor.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows multiple messages to be queued without leaving ready state", async () => {
|
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
|
|
||||||
@@ -278,21 +213,6 @@ describe("chatMachine transitions", () => {
|
|||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
}),
|
||||||
unlockPrivateMessage: fromPromise<
|
|
||||||
UnlockPrivateOutput,
|
|
||||||
{ messageId: string }
|
|
||||||
>(async ({ input }) => ({
|
|
||||||
messageId: input.messageId,
|
|
||||||
response: UnlockPrivateResponse.from({
|
|
||||||
unlocked: true,
|
|
||||||
content: "unlocked",
|
|
||||||
showUpgrade: false,
|
|
||||||
paywallTriggered: false,
|
|
||||||
privateFreeLimit: 0,
|
|
||||||
privateUsedToday: 0,
|
|
||||||
reason: "ok",
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine).start();
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ interface ChatState {
|
|||||||
upgradeReason: MachineContext["upgradeReason"];
|
upgradeReason: MachineContext["upgradeReason"];
|
||||||
upgradeHint: MachineContext["upgradeHint"];
|
upgradeHint: MachineContext["upgradeHint"];
|
||||||
upgradeDetail: MachineContext["upgradeDetail"];
|
upgradeDetail: MachineContext["upgradeDetail"];
|
||||||
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -52,7 +51,6 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
upgradeReason: state.context.upgradeReason,
|
upgradeReason: state.context.upgradeReason,
|
||||||
upgradeHint: state.context.upgradeHint,
|
upgradeHint: state.context.upgradeHint,
|
||||||
upgradeDetail: state.context.upgradeDetail,
|
upgradeDetail: state.context.upgradeDetail,
|
||||||
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
|
||||||
isLoadingMore: state.context.isLoadingMore,
|
isLoadingMore: state.context.isLoadingMore,
|
||||||
hasMore: state.context.hasMore,
|
hasMore: state.context.hasMore,
|
||||||
historyOffset: state.context.historyOffset,
|
historyOffset: state.context.historyOffset,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* chat 机器不感知鉴权。
|
* chat 机器不感知鉴权。
|
||||||
*
|
*
|
||||||
* 鉴权生命周期事件(本轮新增):
|
* 鉴权生命周期事件(本轮新增):
|
||||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏,不连 WS
|
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏
|
||||||
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏,不连 WS
|
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏
|
||||||
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
||||||
*
|
*
|
||||||
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||||
@@ -23,7 +23,6 @@ export type ChatEvent =
|
|||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
|
||||||
| { type: "ChatLoadMoreHistory" }
|
| { type: "ChatLoadMoreHistory" }
|
||||||
| { type: "ChatQueuedSendStarted" }
|
| { type: "ChatQueuedSendStarted" }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -73,41 +73,6 @@ export const loadMoreHistoryActor = fromPromise<
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const unlockPrivateMessageActor = fromPromise<
|
|
||||||
{
|
|
||||||
messageId: string;
|
|
||||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
|
||||||
},
|
|
||||||
{ messageId: string }
|
|
||||||
>(async ({ input }) => {
|
|
||||||
const result = await chatRepo.unlockPrivateMessage(input.messageId);
|
|
||||||
if (Result.isErr(result)) {
|
|
||||||
log.error("[chat-machine] unlockPrivateMessageActor failed", {
|
|
||||||
messageId: input.messageId,
|
|
||||||
error: result.error,
|
|
||||||
});
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data.unlocked && result.data.content != null) {
|
|
||||||
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
|
||||||
input.messageId,
|
|
||||||
result.data.content,
|
|
||||||
);
|
|
||||||
if (Result.isErr(localResult)) {
|
|
||||||
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
|
|
||||||
messageId: input.messageId,
|
|
||||||
error: localResult.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: input.messageId,
|
|
||||||
response: result.data,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||||
({ sendBack, receive }) => {
|
({ sendBack, receive }) => {
|
||||||
return createMessageQueueActor(sendBack, receive);
|
return createMessageQueueActor(sendBack, receive);
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* Chat 状态机(XState v5)
|
* Chat 状态机(XState v5)
|
||||||
*
|
*
|
||||||
* 鉴权解耦(事件驱动):
|
* 鉴权解耦(事件驱动):
|
||||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
* chat 机器不感知鉴权 —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||||
* ChatAuthSync 派发登录态生命周期事件:
|
* ChatAuthSync 派发登录态生命周期事件:
|
||||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
* - `ChatGuestLogin` → 游客会话
|
||||||
* - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS)
|
* - `ChatUserLogin { token }` → 其他登录用户会话
|
||||||
* - `ChatLogout` → 正式登录用户登出
|
* - `ChatLogout` → 正式登录用户登出
|
||||||
*
|
*
|
||||||
* 登录态流转约束:
|
* 登录态流转约束:
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
*
|
*
|
||||||
* 状态结构(parent state 模式):
|
* 状态结构(parent state 模式):
|
||||||
* - `idle`:屏没挂 / 登出
|
* - `idle`:屏没挂 / 登出
|
||||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
* - `guestSession`(parent):游客会话
|
||||||
* - `userSession`(parent):其他登录用户会话 —— 不 invoke WS
|
* - `userSession`(parent):其他登录用户会话
|
||||||
*
|
*
|
||||||
* init 任务:
|
* init 任务:
|
||||||
* - guestSession.initializing:loadHistory
|
* - guestSession.initializing:loadHistory
|
||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
sendMessageHttpActor,
|
sendMessageHttpActor,
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
httpMessageQueueActor,
|
httpMessageQueueActor,
|
||||||
unlockPrivateMessageActor,
|
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
@@ -73,7 +72,6 @@ export const chatMachine = setup({
|
|||||||
sendMessageHttp: sendMessageHttpActor,
|
sendMessageHttp: sendMessageHttpActor,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
httpMessageQueue: httpMessageQueueActor,
|
httpMessageQueue: httpMessageQueueActor,
|
||||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||||
@@ -195,7 +193,7 @@ export const chatMachine = setup({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
appendSocketErrorMessage: assign(({ context }) => {
|
appendQueuedSendErrorMessage: assign(({ context }) => {
|
||||||
const messages = [
|
const messages = [
|
||||||
...context.messages,
|
...context.messages,
|
||||||
{
|
{
|
||||||
@@ -213,69 +211,6 @@ export const chatMachine = setup({
|
|||||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||||
return applyHttpSendOutput(context, event.output);
|
return applyHttpSendOutput(context, event.output);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setUnlockingPrivateMessage: assign(({ event }) => {
|
|
||||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
|
||||||
return {
|
|
||||||
unlockingPrivateMessageId: event.messageId,
|
|
||||||
upgradePromptVisible: false,
|
|
||||||
upgradeReason: null,
|
|
||||||
upgradeHint: null,
|
|
||||||
upgradeDetail: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
|
||||||
if (!("output" in event)) return {};
|
|
||||||
const output = event.output as unknown as {
|
|
||||||
messageId: string;
|
|
||||||
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
|
||||||
};
|
|
||||||
const { messageId, response } = output;
|
|
||||||
|
|
||||||
if (response.unlocked && response.content != null) {
|
|
||||||
return {
|
|
||||||
messages: context.messages.map((message) =>
|
|
||||||
message.id === messageId
|
|
||||||
? {
|
|
||||||
...message,
|
|
||||||
content: response.content ?? message.content,
|
|
||||||
locked: false,
|
|
||||||
lockedPrivate: false,
|
|
||||||
privateMessageHint: null,
|
|
||||||
isPrivate: message.isPrivate ?? true,
|
|
||||||
}
|
|
||||||
: message,
|
|
||||||
),
|
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
upgradePromptVisible: false,
|
|
||||||
upgradeReason: null,
|
|
||||||
upgradeHint: null,
|
|
||||||
upgradeDetail: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.showUpgrade) {
|
|
||||||
return {
|
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
upgradePromptVisible: true,
|
|
||||||
upgradeReason: "private_message",
|
|
||||||
upgradeHint: null,
|
|
||||||
upgradeDetail: {
|
|
||||||
usedToday: response.privateUsedToday,
|
|
||||||
limit: response.privateFreeLimit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
clearUnlockingPrivateMessage: assign({
|
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
@@ -312,7 +247,7 @@ export const chatMachine = setup({
|
|||||||
actions: "applyQueuedHttpOutput",
|
actions: "applyQueuedHttpOutput",
|
||||||
},
|
},
|
||||||
ChatQueuedSendError: {
|
ChatQueuedSendError: {
|
||||||
actions: "appendSocketErrorMessage",
|
actions: "appendQueuedSendErrorMessage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
@@ -354,11 +289,7 @@ export const chatMachine = setup({
|
|||||||
actions: "appendGuestUserImage",
|
actions: "appendGuestUserImage",
|
||||||
target: "sending",
|
target: "sending",
|
||||||
},
|
},
|
||||||
ChatUnlockPrivateMessage: {
|
// 游客不支持翻页历史,发送消息统一走 HTTP 队列。
|
||||||
actions: "setUnlockingPrivateMessage",
|
|
||||||
target: "unlockingPrivate",
|
|
||||||
},
|
|
||||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sending: {
|
sending: {
|
||||||
@@ -379,23 +310,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
unlockingPrivate: {
|
|
||||||
invoke: {
|
|
||||||
src: "unlockPrivateMessage",
|
|
||||||
input: ({ event }) => ({
|
|
||||||
messageId:
|
|
||||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
|
||||||
}),
|
|
||||||
onDone: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "applyUnlockPrivateOutput",
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "clearUnlockingPrivateMessage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -421,7 +335,7 @@ export const chatMachine = setup({
|
|||||||
actions: "applyQueuedHttpOutput",
|
actions: "applyQueuedHttpOutput",
|
||||||
},
|
},
|
||||||
ChatQueuedSendError: {
|
ChatQueuedSendError: {
|
||||||
actions: "appendSocketErrorMessage",
|
actions: "appendQueuedSendErrorMessage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
@@ -460,10 +374,6 @@ export const chatMachine = setup({
|
|||||||
ChatLoadMoreHistory: {
|
ChatLoadMoreHistory: {
|
||||||
target: "loadingMore",
|
target: "loadingMore",
|
||||||
},
|
},
|
||||||
ChatUnlockPrivateMessage: {
|
|
||||||
actions: "setUnlockingPrivateMessage",
|
|
||||||
target: "unlockingPrivate",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sendingViaHttp: {
|
sendingViaHttp: {
|
||||||
@@ -503,23 +413,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
unlockingPrivate: {
|
|
||||||
invoke: {
|
|
||||||
src: "unlockPrivateMessage",
|
|
||||||
input: ({ event }) => ({
|
|
||||||
messageId:
|
|
||||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
|
||||||
}),
|
|
||||||
onDone: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "applyUnlockPrivateOutput",
|
|
||||||
},
|
|
||||||
onError: {
|
|
||||||
target: "ready",
|
|
||||||
actions: "clearUnlockingPrivateMessage",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface ChatState {
|
|||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
pendingReplyCount: number;
|
pendingReplyCount: number;
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: "daily_limit" | "private_message" | "image" | null;
|
upgradeReason: "daily_limit" | "image" | null;
|
||||||
upgradeHint: string | null;
|
upgradeHint: string | null;
|
||||||
upgradeDetail:
|
upgradeDetail:
|
||||||
| {
|
| {
|
||||||
@@ -15,7 +15,6 @@ export interface ChatState {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
unlockingPrivateMessageId: string | null;
|
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -34,7 +33,6 @@ export const initialState: ChatState = {
|
|||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
upgradeDetail: null,
|
upgradeDetail: null,
|
||||||
unlockingPrivateMessageId: null,
|
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user