diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index b74cd6ae..2e11e18b 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -49,12 +49,6 @@ export function ChatScreen() { state.upgradePromptVisible && state.upgradeReason === "daily_limit"; 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 chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", { returnTo: "chat", @@ -129,7 +123,7 @@ export function ChatScreen() { UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); } - function handleUnlockPrivateMessage(): void { + function openChatPaywallSubscription(): void { if (isGuest) { router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl)); return; @@ -137,28 +131,20 @@ export function ChatScreen() { router.push(chatPaywallSubscriptionUrl); } + function handleUnlockPrivateMessage(): void { + openChatPaywallSubscription(); + } + function handleUnlockImagePaywall(): void { - if (isGuest) { - router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl)); - return; - } - router.push(chatPaywallSubscriptionUrl); + openChatPaywallSubscription(); } function handleMessageLimitUnlock(): void { - if (isGuest) { - router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl)); - return; - } - router.push(chatPaywallSubscriptionUrl); + openChatPaywallSubscription(); } function handleUnlockVoiceMessage(): void { - if (isGuest) { - router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl)); - return; - } - router.push(chatPaywallSubscriptionUrl); + openChatPaywallSubscription(); } return ( @@ -183,19 +169,11 @@ export function ChatScreen() { messages={state.messages} isReplyingAI={state.isReplyingAI} isGuest={isGuest} - unlockingPrivateMessageId={state.unlockingPrivateMessageId} onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockImagePaywall={handleUnlockImagePaywall} onUnlockVoiceMessage={handleUnlockVoiceMessage} /> - {showPrivatePaywallBanner ? ( - - ) : null} - {showMessageLimitBanner ? ( void; + onUnlockPrivateMessage?: () => void; onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } @@ -35,7 +34,6 @@ export interface ChatAreaProps { export function ChatArea({ messages, isReplyingAI, - unlockingPrivateMessageId, onUnlockPrivateMessage, onUnlockImagePaywall, onUnlockVoiceMessage, @@ -60,7 +58,6 @@ export function ChatArea({ {renderMessagesWithDateHeaders( messages, - unlockingPrivateMessageId, onUnlockPrivateMessage, onUnlockImagePaywall, onUnlockVoiceMessage, @@ -74,8 +71,7 @@ export function ChatArea({ /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], - unlockingPrivateMessageId?: string | null, - onUnlockPrivateMessage?: (messageId: string) => void, + onUnlockPrivateMessage?: () => void, onUnlockImagePaywall?: () => void, onUnlockVoiceMessage?: () => void, ) { @@ -94,7 +90,6 @@ function renderMessagesWithDateHeaders( ) : ( void; + onUnlockPrivateMessage?: () => void; onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } export function MessageBubble({ - id, content, imageUrl, imagePaywalled, @@ -42,7 +39,6 @@ export function MessageBubble({ lockReason, lockedPrivate, privateMessageHint, - isUnlockingPrivate, onUnlockPrivateMessage, onUnlockImagePaywall, onUnlockVoiceMessage, @@ -55,7 +51,6 @@ export function MessageBubble({
{/* 占位 */} void; + onUnlockPrivateMessage?: () => void; onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } @@ -24,7 +22,6 @@ export interface MessageContentProps { const IMAGE_PLACEHOLDER = "[图片]"; export function MessageContent({ - messageId, content, imageUrl, imagePaywalled, @@ -34,7 +31,6 @@ export function MessageContent({ lockReason, lockedPrivate, privateMessageHint, - isUnlockingPrivate = false, onUnlockPrivateMessage, onUnlockImagePaywall, onUnlockVoiceMessage, @@ -63,12 +59,8 @@ export function MessageContent({ {isLockedPrivateMessage ? ( onUnlockPrivateMessage?.(messageId) - : undefined - } + isUnlocking={false} + onUnlock={onUnlockPrivateMessage} /> ) : ( <> @@ -85,7 +77,7 @@ export function MessageContent({ isFromAI={isFromAI} locked={isLockedVoiceMessage} hint={privateMessageHint} - isUnlocking={isUnlockingPrivate} + isUnlocking={false} onUnlock={ isLockedVoiceMessage ? onUnlockVoiceMessage diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index e7f3655c..296f8ada 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -39,7 +39,6 @@ function makeChatState(overrides: Partial = {}): ChatState { upgradeReason: null, upgradeHint: null, upgradeDetail: null, - unlockingPrivateMessageId: null, isLoadingMore: false, hasMore: true, historyOffset: 0, diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 5c63db98..4aeb7666 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -3,7 +3,6 @@ import { createActor, fromCallback, fromPromise, waitFor } from "xstate"; import { ChatSendResponse, - UnlockPrivateResponse, type UiMessage, } from "@/data/dto/chat"; import { chatMachine } from "@/stores/chat/chat-machine"; @@ -29,11 +28,6 @@ interface SendMessageHttpOutput { reply: UiMessage | null; } -interface UnlockPrivateOutput { - messageId: string; - response: UnlockPrivateResponse; -} - function makeChatSendResponse(): ChatSendResponse { return ChatSendResponse.from({ reply: "", @@ -56,7 +50,6 @@ function makeChatSendResponse(): ChatSendResponse { function createTestChatMachine( options: { historyMessages?: UiMessage[]; - unlockedContent?: string; } = {}, ) { return chatMachine.provide({ @@ -97,21 +90,6 @@ function createTestChatMachine( }); 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(); }); - 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 () => { const actor = createActor(createTestChatMachine()).start(); @@ -278,21 +213,6 @@ describe("chatMachine transitions", () => { }); 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(); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 114595ab..c66fe1b7 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -25,7 +25,6 @@ interface ChatState { upgradeReason: MachineContext["upgradeReason"]; upgradeHint: MachineContext["upgradeHint"]; upgradeDetail: MachineContext["upgradeDetail"]; - unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"]; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; @@ -52,7 +51,6 @@ export function ChatProvider({ children }: ChatProviderProps) { upgradeReason: state.context.upgradeReason, upgradeHint: state.context.upgradeHint, upgradeDetail: state.context.upgradeDetail, - unlockingPrivateMessageId: state.context.unlockingPrivateMessageId, isLoadingMore: state.context.isLoadingMore, hasMore: state.context.hasMore, historyOffset: state.context.historyOffset, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 9a1dd65b..0d20a311 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -8,8 +8,8 @@ * chat 机器不感知鉴权。 * * 鉴权生命周期事件(本轮新增): - * - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏,不连 WS - * - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏,不连 WS + * - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏 + * - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏 * - `ChatLogout`:正式登录用户登出 —— 清消息 * * 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, @@ -23,7 +23,6 @@ export type ChatEvent = // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } - | { type: "ChatUnlockPrivateMessage"; messageId: string } | { type: "ChatLoadMoreHistory" } | { type: "ChatQueuedSendStarted" } | { diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 944ed397..e88419e4 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -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( ({ sendBack, receive }) => { return createMessageQueueActor(sendBack, receive); diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index c6b5c744..c37c4c6e 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -2,10 +2,10 @@ * Chat 状态机(XState v5) * * 鉴权解耦(事件驱动): - * chat 机器不感知鉴权 / 不管 WebSocket —— 由 派生 loginStatus + * chat 机器不感知鉴权 —— 由 派生 loginStatus * ChatAuthSync 派发登录态生命周期事件: - * - `ChatGuestLogin` → 游客会话(断 WS = 不连) - * - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS) + * - `ChatGuestLogin` → 游客会话 + * - `ChatUserLogin { token }` → 其他登录用户会话 * - `ChatLogout` → 正式登录用户登出 * * 登录态流转约束: @@ -15,8 +15,8 @@ * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 - * - `guestSession`(parent):游客会话 —— 不 invoke WS - * - `userSession`(parent):其他登录用户会话 —— 不 invoke WS + * - `guestSession`(parent):游客会话 + * - `userSession`(parent):其他登录用户会话 * * init 任务: * - guestSession.initializing:loadHistory @@ -50,7 +50,6 @@ import { sendMessageHttpActor, loadMoreHistoryActor, httpMessageQueueActor, - unlockPrivateMessageActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -73,7 +72,6 @@ export const chatMachine = setup({ sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, httpMessageQueue: httpMessageQueueActor, - unlockPrivateMessage: unlockPrivateMessageActor, }, actions: { enqueueMessage: sendTo("messageQueue", ({ event }) => event), @@ -195,7 +193,7 @@ export const chatMachine = setup({ }; }), - appendSocketErrorMessage: assign(({ context }) => { + appendQueuedSendErrorMessage: assign(({ context }) => { const messages = [ ...context.messages, { @@ -213,69 +211,6 @@ export const chatMachine = setup({ if (event.type !== "ChatQueuedHttpDone") return {}; 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({ id: "chat", @@ -312,7 +247,7 @@ export const chatMachine = setup({ actions: "applyQueuedHttpOutput", }, ChatQueuedSendError: { - actions: "appendSocketErrorMessage", + actions: "appendQueuedSendErrorMessage", }, }, initial: "initializing", @@ -354,11 +289,7 @@ export const chatMachine = setup({ actions: "appendGuestUserImage", target: "sending", }, - ChatUnlockPrivateMessage: { - actions: "setUnlockingPrivateMessage", - target: "unlockingPrivate", - }, - // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS + // 游客不支持翻页历史,发送消息统一走 HTTP 队列。 }, }, 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", }, ChatQueuedSendError: { - actions: "appendSocketErrorMessage", + actions: "appendQueuedSendErrorMessage", }, }, initial: "initializing", @@ -460,10 +374,6 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, - ChatUnlockPrivateMessage: { - actions: "setUnlockingPrivateMessage", - target: "unlockingPrivate", - }, }, }, 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", - }, - }, - }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 54f727e6..26efd731 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -5,7 +5,7 @@ export interface ChatState { isReplyingAI: boolean; pendingReplyCount: number; upgradePromptVisible: boolean; - upgradeReason: "daily_limit" | "private_message" | "image" | null; + upgradeReason: "daily_limit" | "image" | null; upgradeHint: string | null; upgradeDetail: | { @@ -15,7 +15,6 @@ export interface ChatState { limit?: number; } | null; - unlockingPrivateMessageId: string | null; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; @@ -34,7 +33,6 @@ export const initialState: ChatState = { upgradeReason: null, upgradeHint: null, upgradeDetail: null, - unlockingPrivateMessageId: null, isLoadingMore: false, hasMore: true, historyOffset: 0,