diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3005ec55..8d174856 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -7,11 +7,18 @@ import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useUserState } from "@/stores/user/user-context"; +import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { openChatInExternalBrowser, recordExternalBrowserPromptShown, resolveExternalBrowserPromptEligibility, } from "@/lib/chat/chat_external_browser"; +import { + consumePendingChatUnlock, + peekPendingChatUnlock, + savePendingChatUnlock, + type PendingChatUnlockKind, +} from "@/lib/navigation/chat_unlock_session"; import { MobileShell } from "@/app/_components/core"; @@ -45,6 +52,9 @@ export function ChatScreen() { // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); + const isAuthenticatedUser = + authState.loginStatus !== "notLoggedIn" && + authState.loginStatus !== "guest"; // 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。 const showMessageLimitBanner = @@ -102,17 +112,79 @@ export function ChatScreen() { authState.loginStatus, ]); + useEffect(() => { + if (!state.historyLoaded || !isAuthenticatedUser) return; + + const pending = peekPendingChatUnlock(); + if (!pending || pending.returnUrl !== ROUTES.chat) return; + + const consumed = consumePendingChatUnlock(); + if (!consumed) return; + + chatDispatch({ + type: "ChatUnlockMessageRequested", + messageId: consumed.messageId, + kind: consumed.kind, + }); + }, [ + chatDispatch, + isAuthenticatedUser, + state.historyLoaded, + ]); + + useEffect(() => { + const request = state.unlockPaywallRequest; + if (!request) return; + + savePendingChatUnlock({ + messageId: request.messageId, + kind: request.kind, + returnUrl: ROUTES.chat, + stage: "payment", + }); + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + router.push( + ROUTE_BUILDERS.subscription( + getInsufficientCreditsSubscriptionType(userState.isVip), + { returnTo: "chat" }, + ), + ); + }, [ + chatDispatch, + router, + state.unlockPaywallRequest, + userState.isVip, + ]); + async function handleOpenExternalBrowser(): Promise { setShowExternalBrowserDialog(false); await openChatInExternalBrowser(); } - function openChatPaywallSubscription(): void { - router.push(getChatPaywallNavigationUrl(authState.loginStatus)); + function requestMessageUnlock( + messageId: string, + kind: PendingChatUnlockKind, + ): void { + if (!isAuthenticatedUser) { + savePendingChatUnlock({ + messageId, + kind, + returnUrl: ROUTES.chat, + stage: "auth", + }); + router.push(ROUTE_BUILDERS.authWithRedirect(ROUTES.chat)); + return; + } + + chatDispatch({ + type: "ChatUnlockMessageRequested", + messageId, + kind, + }); } - function handleUnlockPrivateMessage(): void { - openChatPaywallSubscription(); + function handleUnlockPrivateMessage(messageId: string): void { + requestMessageUnlock(messageId, "private"); } function handleMessageLimitUnlock(): void { @@ -129,8 +201,8 @@ export function ChatScreen() { ); } - function handleUnlockVoiceMessage(): void { - openChatPaywallSubscription(); + function handleUnlockVoiceMessage(messageId: string): void { + requestMessageUnlock(messageId, "voice"); } return ( @@ -154,6 +226,8 @@ export function ChatScreen() { messages={state.messages} isReplyingAI={state.isReplyingAI} isGuest={isGuest} + isUnlockingMessage={state.isUnlockingMessage} + unlockingMessageId={state.unlockingMessageId} onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockVoiceMessage={handleUnlockVoiceMessage} /> diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index c26d237f..0abb3b12 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -26,13 +26,17 @@ export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; isGuest: boolean; - onUnlockPrivateMessage?: () => void; - onUnlockVoiceMessage?: () => void; + isUnlockingMessage?: boolean; + unlockingMessageId?: string | null; + onUnlockPrivateMessage?: (messageId: string) => void; + onUnlockVoiceMessage?: (messageId: string) => void; } export function ChatArea({ messages, isReplyingAI, + isUnlockingMessage, + unlockingMessageId, onUnlockPrivateMessage, onUnlockVoiceMessage, }: ChatAreaProps) { @@ -56,6 +60,8 @@ export function ChatArea({ {renderMessagesWithDateHeaders( messages, + isUnlockingMessage, + unlockingMessageId, onUnlockPrivateMessage, onUnlockVoiceMessage, )} @@ -68,8 +74,10 @@ export function ChatArea({ /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], - onUnlockPrivateMessage?: () => void, - onUnlockVoiceMessage?: () => void, + isUnlockingMessage?: boolean, + unlockingMessageId?: string | null, + onUnlockPrivateMessage?: (messageId: string) => void, + onUnlockVoiceMessage?: (messageId: string) => void, ) { const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; @@ -96,6 +104,10 @@ function renderMessagesWithDateHeaders( lockReason={item.message.lockReason} lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} + isUnlockingMessage={ + isUnlockingMessage === true && + item.message.id === unlockingMessageId + } onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} /> diff --git a/src/app/chat/components/fullscreen-image-viewer.module.css b/src/app/chat/components/fullscreen-image-viewer.module.css index 1fd61097..437b7cb4 100644 --- a/src/app/chat/components/fullscreen-image-viewer.module.css +++ b/src/app/chat/components/fullscreen-image-viewer.module.css @@ -91,6 +91,11 @@ transform: translateY(1px); } +.unlockButton:disabled { + cursor: not-allowed; + opacity: 0.72; +} + .loading { width: 32px; height: 32px; diff --git a/src/app/chat/components/fullscreen-image-viewer.tsx b/src/app/chat/components/fullscreen-image-viewer.tsx index d1b4d853..a253ecca 100644 --- a/src/app/chat/components/fullscreen-image-viewer.tsx +++ b/src/app/chat/components/fullscreen-image-viewer.tsx @@ -26,6 +26,7 @@ export interface FullscreenImageViewerProps { messageId?: string; imageUrl: string; imagePaywalled?: boolean; + isUnlockingImagePaywall?: boolean; onUnlockImagePaywall?: () => void; onClose: () => void; } @@ -34,6 +35,7 @@ export function FullscreenImageViewer({ messageId, imageUrl, imagePaywalled = false, + isUnlockingImagePaywall = false, onUnlockImagePaywall, onClose, }: FullscreenImageViewerProps) { @@ -88,9 +90,12 @@ export function FullscreenImageViewer({ ); diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index aa2a8e9f..12ec04a5 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -25,8 +25,9 @@ export interface MessageBubbleProps { lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; - onUnlockPrivateMessage?: () => void; - onUnlockVoiceMessage?: () => void; + isUnlockingMessage?: boolean; + onUnlockPrivateMessage?: (messageId: string) => void; + onUnlockVoiceMessage?: (messageId: string) => void; } export function MessageBubble({ @@ -40,6 +41,7 @@ export function MessageBubble({ lockReason, lockedPrivate, privateMessageHint, + isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, }: MessageBubbleProps) { @@ -61,6 +63,7 @@ export function MessageBubble({ lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} + isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} /> @@ -83,6 +86,7 @@ export function MessageBubble({ lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} + isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} /> diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index c32e671f..94db5684 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -15,8 +15,9 @@ export interface MessageContentProps { lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; - onUnlockPrivateMessage?: () => void; - onUnlockVoiceMessage?: () => void; + isUnlockingMessage?: boolean; + onUnlockPrivateMessage?: (messageId: string) => void; + onUnlockVoiceMessage?: (messageId: string) => void; } const IMAGE_PLACEHOLDER = "[图片]"; @@ -32,6 +33,7 @@ export function MessageContent({ lockReason, lockedPrivate, privateMessageHint, + isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, }: MessageContentProps) { @@ -42,6 +44,14 @@ export function MessageContent({ lockedPrivate === true || (locked === true && lockReason === "private_message"); const isLockedVoiceMessage = locked === true && lockReason === "voice_message"; + const handleUnlockPrivateMessage = + messageId && onUnlockPrivateMessage + ? () => onUnlockPrivateMessage(messageId) + : undefined; + const handleUnlockVoiceMessage = + isLockedVoiceMessage && messageId && onUnlockVoiceMessage + ? () => onUnlockVoiceMessage(messageId) + : undefined; return (
) : ( <> @@ -78,12 +88,8 @@ export function MessageContent({ isFromAI={isFromAI} locked={isLockedVoiceMessage} hint={privateMessageHint} - isUnlocking={false} - onUnlock={ - isLockedVoiceMessage - ? onUnlockVoiceMessage - : undefined - } + isUnlocking={isUnlockingMessage} + onUnlock={handleUnlockVoiceMessage} /> ) : null} {hasText && !hasAudio ? ( diff --git a/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx index 069b4cac..f46f4382 100644 --- a/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx +++ b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx @@ -1,14 +1,20 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { MobileShell } from "@/app/_components/core"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; -import { savePendingChatImageReturn } from "@/lib/navigation/chat_image_return_session"; +import { useUserState } from "@/stores/user/user-context"; +import { + consumePendingChatUnlock, + peekPendingChatUnlock, + savePendingChatUnlock, +} from "@/lib/navigation/chat_unlock_session"; -import { getChatPaywallNavigationUrl } from "../../chat-screen.helpers"; +import { getInsufficientCreditsSubscriptionType } from "../../chat-screen.helpers"; import { FullscreenImageViewer, HistoryUnlockDialog } from "../../components"; import styles from "./chat-image-viewer-screen.module.css"; @@ -21,22 +27,95 @@ export function ChatImageViewerScreen({ }: ChatImageViewerScreenProps) { const router = useRouter(); const authState = useAuthState(); + const userState = useUserState(); const chatState = useChatState(); const chatDispatch = useChatDispatch(); + const returnUrl = ROUTE_BUILDERS.chatImage(messageId); + const isAuthenticatedUser = + authState.loginStatus !== "notLoggedIn" && + authState.loginStatus !== "guest"; const message = chatState.messages.find( (item) => item.id === messageId && item.imageUrl, ); + useEffect(() => { + if (!chatState.historyLoaded || !isAuthenticatedUser) return; + + const pending = peekPendingChatUnlock(); + if ( + !pending || + pending.kind !== "image" || + pending.messageId !== messageId || + pending.returnUrl !== returnUrl + ) { + return; + } + + const consumed = consumePendingChatUnlock(); + if (!consumed) return; + + chatDispatch({ + type: "ChatUnlockMessageRequested", + messageId: consumed.messageId, + kind: consumed.kind, + }); + }, [ + chatDispatch, + chatState.historyLoaded, + isAuthenticatedUser, + messageId, + returnUrl, + ]); + + useEffect(() => { + const request = chatState.unlockPaywallRequest; + if (!request || request.kind !== "image" || request.messageId !== messageId) { + return; + } + + savePendingChatUnlock({ + messageId: request.messageId, + kind: request.kind, + returnUrl, + stage: "payment", + }); + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + router.push( + ROUTE_BUILDERS.subscription( + getInsufficientCreditsSubscriptionType(userState.isVip), + { returnTo: "chat" }, + ), + ); + }, [ + chatDispatch, + chatState.unlockPaywallRequest, + messageId, + returnUrl, + router, + userState.isVip, + ]); + const handleClose = () => { router.push(ROUTES.chat); }; const handleUnlockImagePaywall = () => { - savePendingChatImageReturn({ + if (!isAuthenticatedUser) { + savePendingChatUnlock({ + messageId, + kind: "image", + returnUrl, + stage: "auth", + }); + router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl)); + return; + } + + chatDispatch({ + type: "ChatUnlockMessageRequested", messageId, - returnUrl: ROUTE_BUILDERS.chatImage(messageId), + kind: "image", }); - router.push(getChatPaywallNavigationUrl(authState.loginStatus)); }; const historyUnlockDialog = ( @@ -79,6 +158,10 @@ export function ChatImageViewerScreen({ messageId={messageId} imageUrl={message.imageUrl ?? ""} imagePaywalled={message.imagePaywalled === true} + isUnlockingImagePaywall={ + chatState.isUnlockingMessage && + chatState.unlockingMessageId === messageId + } onUnlockImagePaywall={handleUnlockImagePaywall} onClose={handleClose} /> diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index 9f8219ff..2a7f217a 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { consumeSubscriptionExitUrl, - consumeSubscriptionExplicitExitUrl, + peekSubscriptionExplicitExitUrl, } from "@/lib/navigation/subscription_exit"; import { clearPendingPaymentOrder, @@ -128,7 +128,7 @@ export function useSubscriptionPaymentFlow({ const handlePaymentSuccessClose = () => { setShowPaymentSuccessDialog(false); paymentDispatch({ type: "PaymentReset" }); - const pendingExitUrl = consumeSubscriptionExplicitExitUrl(); + const pendingExitUrl = peekSubscriptionExplicitExitUrl(); if (pendingExitUrl) { router.replace(pendingExitUrl); return; diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts index e936fce2..45815005 100644 --- a/src/lib/navigation/__tests__/subscription_exit.test.ts +++ b/src/lib/navigation/__tests__/subscription_exit.test.ts @@ -3,23 +3,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ROUTES } from "@/router/routes"; import { consumePendingChatImageReturn } from "../chat_image_return_session"; +import { + clearPendingChatUnlock, + peekPendingChatUnlock, +} from "../chat_unlock_session"; import { consumeSubscriptionExitUrl, consumeSubscriptionExplicitExitUrl, getSubscriptionFallbackExitUrl, + peekSubscriptionExplicitExitUrl, } from "../subscription_exit"; vi.mock("../chat_image_return_session", () => ({ consumePendingChatImageReturn: vi.fn(), })); +vi.mock("../chat_unlock_session", () => ({ + clearPendingChatUnlock: vi.fn(), + peekPendingChatUnlock: vi.fn(), +})); const consumePendingChatImageReturnMock = vi.mocked( consumePendingChatImageReturn, ); +const clearPendingChatUnlockMock = vi.mocked(clearPendingChatUnlock); +const peekPendingChatUnlockMock = vi.mocked(peekPendingChatUnlock); describe("subscription exit helpers", () => { beforeEach(() => { consumePendingChatImageReturnMock.mockReset(); + clearPendingChatUnlockMock.mockReset(); + peekPendingChatUnlockMock.mockReset(); }); it("uses explicit chat image return urls first", () => { @@ -35,14 +48,45 @@ describe("subscription exit helpers", () => { it("falls back to chat when requested", () => { consumePendingChatImageReturnMock.mockReturnValue(null); + peekPendingChatUnlockMock.mockReturnValue(null); expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat); }); it("falls back to sidebar by default", () => { consumePendingChatImageReturnMock.mockReturnValue(null); + peekPendingChatUnlockMock.mockReturnValue(null); expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar); expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar); }); + + it("peeks chat unlock return urls without clearing them", () => { + peekPendingChatUnlockMock.mockReturnValue({ + reason: "single_message_unlock", + messageId: "msg_1", + kind: "image", + returnUrl: "/chat/image/msg_1", + stage: "payment", + createdAt: 1, + }); + + expect(peekSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); + expect(clearPendingChatUnlockMock).not.toHaveBeenCalled(); + }); + + it("clears chat unlock return urls when consumed", () => { + consumePendingChatImageReturnMock.mockReturnValue(null); + peekPendingChatUnlockMock.mockReturnValue({ + reason: "single_message_unlock", + messageId: "msg_1", + kind: "image", + returnUrl: "/chat/image/msg_1", + stage: "payment", + createdAt: 1, + }); + + expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); + expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/navigation/chat_unlock_session.ts b/src/lib/navigation/chat_unlock_session.ts new file mode 100644 index 00000000..ce51c14c --- /dev/null +++ b/src/lib/navigation/chat_unlock_session.ts @@ -0,0 +1,109 @@ +"use client"; + +const STORAGE_KEY = "cozsweet.chat.pendingUnlock"; +const MAX_AGE_MS = 30 * 60 * 1000; + +export type PendingChatUnlockKind = "private" | "voice" | "image"; +export type PendingChatUnlockStage = "auth" | "payment"; + +export interface PendingChatUnlock { + reason: "single_message_unlock"; + messageId: string; + kind: PendingChatUnlockKind; + returnUrl: string; + stage: PendingChatUnlockStage; + createdAt: number; +} + +export function savePendingChatUnlock(input: { + messageId: string; + kind: PendingChatUnlockKind; + returnUrl: string; + stage: PendingChatUnlockStage; +}): void { + if (typeof window === "undefined") return; + const payload: PendingChatUnlock = { + reason: "single_message_unlock", + messageId: input.messageId, + kind: input.kind, + returnUrl: input.returnUrl, + stage: input.stage, + createdAt: Date.now(), + }; + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); +} + +export function consumePendingChatUnlock(): PendingChatUnlock | null { + if (typeof window === "undefined") return null; + + const raw = window.sessionStorage.getItem(STORAGE_KEY); + window.sessionStorage.removeItem(STORAGE_KEY); + if (!raw) return null; + + return parsePendingChatUnlock(raw); +} + +export function peekPendingChatUnlock(): PendingChatUnlock | null { + if (typeof window === "undefined") return null; + + const raw = window.sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + const value = parsePendingChatUnlock(raw); + if (!value) { + window.sessionStorage.removeItem(STORAGE_KEY); + } + return value; +} + +export function hasPendingChatUnlock(): boolean { + return peekPendingChatUnlock() !== null; +} + +export function clearPendingChatUnlock(): void { + if (typeof window === "undefined") return; + window.sessionStorage.removeItem(STORAGE_KEY); +} + +function parsePendingChatUnlock(raw: string): PendingChatUnlock | null { + try { + const value = JSON.parse(raw) as Partial; + if (value.reason !== "single_message_unlock") return null; + if (typeof value.messageId !== "string" || value.messageId.length === 0) { + return null; + } + if (!isPendingChatUnlockKind(value.kind)) return null; + if (!isPendingChatUnlockStage(value.stage)) return null; + if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) { + return null; + } + if (!value.returnUrl.startsWith("/") || value.returnUrl.startsWith("//")) { + return null; + } + if (typeof value.createdAt !== "number") return null; + if (Date.now() - value.createdAt > MAX_AGE_MS) return null; + + return { + reason: value.reason, + messageId: value.messageId, + kind: value.kind, + returnUrl: value.returnUrl, + stage: value.stage, + createdAt: value.createdAt, + }; + } catch { + return null; + } +} + +function isPendingChatUnlockKind( + value: unknown, +): value is PendingChatUnlockKind { + return value === "private" || value === "voice" || value === "image"; +} + +function isPendingChatUnlockStage( + value: unknown, +): value is PendingChatUnlockStage { + return value === "auth" || value === "payment"; +} diff --git a/src/lib/navigation/subscription_exit.ts b/src/lib/navigation/subscription_exit.ts index 561a31dc..08f0cc87 100644 --- a/src/lib/navigation/subscription_exit.ts +++ b/src/lib/navigation/subscription_exit.ts @@ -3,12 +3,27 @@ import { ROUTES } from "@/router/routes"; import { consumePendingChatImageReturn } from "./chat_image_return_session"; +import { + clearPendingChatUnlock, + peekPendingChatUnlock, +} from "./chat_unlock_session"; export type SubscriptionReturnTo = "chat" | null; export function consumeSubscriptionExplicitExitUrl(): string | null { const pendingImageReturn = consumePendingChatImageReturn(); if (pendingImageReturn) return pendingImageReturn.returnUrl; + const pendingChatUnlock = peekPendingChatUnlock(); + if (pendingChatUnlock) { + clearPendingChatUnlock(); + return pendingChatUnlock.returnUrl; + } + return null; +} + +export function peekSubscriptionExplicitExitUrl(): string | null { + const pendingChatUnlock = peekPendingChatUnlock(); + if (pendingChatUnlock) return pendingChatUnlock.returnUrl; return null; } diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 38ea1619..7115f3a9 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -53,6 +53,11 @@ function makeChatState(overrides: Partial = {}): ChatState { lockedHistoryCount: 0, isUnlockingHistory: false, unlockHistoryError: null, + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: null, + unlockPaywallRequest: null, ...overrides, }; } diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 8fb4474a..2de59909 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -3,6 +3,7 @@ import { createActor, fromCallback, fromPromise, waitFor } from "xstate"; import { ChatSendResponse, + UnlockPrivateResponse, type UiMessage, } from "@/data/dto/chat"; import { chatMachine } from "@/stores/chat/chat-machine"; @@ -37,6 +38,11 @@ interface UnlockHistoryOutput { newOffset: number; } +interface UnlockMessageOutput { + messageId: string; + response: UnlockPrivateResponse; +} + function makeChatSendResponse(): ChatSendResponse { return ChatSendResponse.from({ reply: "", @@ -56,10 +62,34 @@ function makeChatSendResponse(): ChatSendResponse { }); } +function makeUnlockPrivateResponse( + overrides: Partial[0]> = {}, +): UnlockPrivateResponse { + return UnlockPrivateResponse.from({ + unlocked: true, + content: "unlocked content", + reason: "ok", + creditBalance: 90, + creditsCharged: 10, + requiredCredits: 10, + shortfallCredits: 0, + lockDetail: { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + ...overrides, + }); +} + function createTestChatMachine( options: { historyMessages?: UiMessage[]; unlockHistoryOutput?: UnlockHistoryOutput; + unlockMessageOutput?: UnlockMessageOutput; } = {}, ) { return chatMachine.provide({ @@ -109,6 +139,13 @@ function createTestChatMachine( newOffset: 0, ...options.unlockHistoryOutput, })), + unlockMessage: fromPromise( + async ({ input }) => + options.unlockMessageOutput ?? { + messageId: input.messageId, + response: makeUnlockPrivateResponse(), + }, + ), }, }); } @@ -437,6 +474,128 @@ describe("chatMachine transitions", () => { actor.stop(); }); + it("unlocks a single private message without navigating to payment", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + id: "msg-private-locked", + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "private_message", + lockedPrivate: true, + privateMessageHint: "A private message is waiting.", + }, + ], + unlockMessageOutput: { + messageId: "msg-private-locked", + response: makeUnlockPrivateResponse({ + content: "This is the unlocked private message.", + }), + }, + }), + ).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ + type: "ChatUnlockMessageRequested", + messageId: "msg-private-locked", + kind: "private", + }); + + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull(); + expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { + id: "msg-private-locked", + content: "This is the unlocked private message.", + locked: false, + lockReason: null, + lockedPrivate: false, + privateMessageHint: null, + }, + ]); + + actor.stop(); + }); + + it("requests payment when single message unlock lacks credits", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + id: "msg-voice-locked", + content: "", + isFromAI: true, + date: "2026-06-29", + audioUrl: "https://example.com/voice.mp3", + locked: true, + lockReason: "voice_message", + privateMessageHint: "A voice message is waiting.", + }, + ], + unlockMessageOutput: { + messageId: "msg-voice-locked", + response: makeUnlockPrivateResponse({ + unlocked: false, + content: "", + reason: "insufficient_balance", + creditBalance: 3, + creditsCharged: 0, + requiredCredits: 10, + shortfallCredits: 7, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "insufficient_balance", + hint: "Insufficient credits.", + detail: { messageId: "msg-voice-locked" }, + }, + }), + }, + }), + ).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ + type: "ChatUnlockMessageRequested", + messageId: "msg-voice-locked", + kind: "voice", + }); + + await waitFor( + actor, + (snapshot) => snapshot.context.unlockPaywallRequest !== null, + ); + + expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({ + messageId: "msg-voice-locked", + kind: "voice", + reason: "insufficient_balance", + creditBalance: 3, + requiredCredits: 10, + shortfallCredits: 7, + }); + expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); + + actor.stop(); + }); + it("clears the weekly limit prompt after payment succeeds", async () => { const actor = createActor(createTestChatMachine()).start(); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index a6c28024..64afb19c 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -32,6 +32,10 @@ interface ChatState { lockedHistoryCount: number; isUnlockingHistory: boolean; unlockHistoryError: string | null; + isUnlockingMessage: boolean; + unlockingMessageId: string | null; + unlockMessageError: string | null; + unlockPaywallRequest: MachineContext["unlockPaywallRequest"]; } const ChatStateCtx = createContext(null); @@ -59,6 +63,10 @@ export function ChatProvider({ children }: ChatProviderProps) { lockedHistoryCount: state.context.lockedHistoryCount, isUnlockingHistory: state.context.isUnlockingHistory, unlockHistoryError: state.context.unlockHistoryError, + isUnlockingMessage: state.context.isUnlockingMessage, + unlockingMessageId: state.context.unlockingMessageId, + unlockMessageError: state.context.unlockMessageError, + unlockPaywallRequest: state.context.unlockPaywallRequest, }), [state], ); diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 052425e5..b8660f9a 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -15,6 +15,8 @@ * 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, * chat-screen 不直接读 AuthStorage(除 token 取值)。 */ +import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; + export type ChatEvent = // 鉴权生命周期(登录态变化后由 ChatAuthSync 派) | { type: "ChatGuestLogin" } @@ -24,6 +26,12 @@ export type ChatEvent = | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } | { type: "ChatLoadMoreHistory" } + | { + type: "ChatUnlockMessageRequested"; + messageId: string; + kind: PendingChatUnlockKind; + } + | { type: "ChatUnlockPaywallNavigationConsumed" } | { type: "ChatPaymentSucceeded" } | { type: "ChatUnlockHistoryConfirmed" } | { type: "ChatUnlockHistoryDismissed" } diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 1a72e90d..b8858553 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -5,7 +5,8 @@ import { fromPromise, fromCallback } from "xstate"; import { MessageQueue } from "@/core/net/message-queue"; -import type { ChatSendResponse } from "@/data/dto/chat"; +import type { ChatLockDetailData } from "@/data/schemas/chat"; +import type { ChatSendResponse, UnlockPrivateResponse } from "@/data/dto/chat"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { Result, Logger } from "@/utils"; @@ -103,6 +104,45 @@ export const unlockHistoryActor = fromPromise<{ }; }); +export interface UnlockMessageOutput { + messageId: string; + response: UnlockPrivateResponse; +} + +export const unlockMessageActor = fromPromise< + UnlockMessageOutput, + { messageId: string } +>(async ({ input }) => { + const chatRepo = getChatRepository(); + const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId); + if (Result.isErr(unlockResult)) { + log.error("[chat-machine] unlockMessageActor failed", { + messageId: input.messageId, + error: unlockResult.error, + }); + throw unlockResult.error; + } + + if (unlockResult.data.unlocked) { + const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( + input.messageId, + unlockResult.data.content, + unlockResult.data.lockDetail as ChatLockDetailData, + ); + if (Result.isErr(markResult)) { + log.warn("[chat-machine] mark unlocked local message failed", { + messageId: input.messageId, + error: markResult.error, + }); + } + } + + return { + messageId: input.messageId, + response: unlockResult.data, + }; +}); + export const httpMessageQueueActor = fromCallback( ({ sendBack, receive }) => { return createMessageQueueActor(sendBack, receive); diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 2c7b7f03..478c7580 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -1,4 +1,5 @@ import type { UiMessage } from "@/data/dto/chat"; +import type { UnlockMessageOutput } from "./chat-machine.actors"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; @@ -142,6 +143,35 @@ export type HttpSendOutput = { reply: UiMessage | null; }; +export function applySingleUnlockOutput( + messages: readonly UiMessage[], + output: UnlockMessageOutput, +): UiMessage[] { + if (!output.response.unlocked) return [...messages]; + + return messages.map((message) => { + if (message.id !== output.messageId) return message; + + const nextContent = + output.response.content.length > 0 + ? output.response.content + : message.content; + + return { + ...message, + content: nextContent, + locked: output.response.lockDetail.locked, + lockReason: output.response.lockDetail.reason, + imagePaywalled: message.imageUrl + ? output.response.lockDetail.locked && + output.response.lockDetail.showUpgrade + : undefined, + lockedPrivate: false, + privateMessageHint: null, + }; + }); +} + export function beginPendingReply(context: ChatState): Pick< ChatState, "isReplyingAI" | "pendingReplyCount" diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 1022729f..fb602f0c 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -36,6 +36,7 @@ import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput, applyHistoryLoadedOutput, + applySingleUnlockOutput, beginPendingReply, countLockedHistoryMessages, finishPendingReply, @@ -48,6 +49,8 @@ import { loadMoreHistoryActor, httpMessageQueueActor, unlockHistoryActor, + unlockMessageActor, + type UnlockMessageOutput, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -71,6 +74,7 @@ export const chatMachine = setup({ loadMoreHistory: loadMoreHistoryActor, httpMessageQueue: httpMessageQueueActor, unlockHistory: unlockHistoryActor, + unlockMessage: unlockMessageActor, }, actions: { enqueueMessage: sendTo("messageQueue", ({ event }) => event), @@ -242,6 +246,78 @@ export const chatMachine = setup({ unlockHistoryPromptVisible: true, unlockHistoryError: "Failed to unlock messages. Please try again.", })), + + markUnlockMessageStarted: assign(({ event }) => { + if (event.type !== "ChatUnlockMessageRequested") return {}; + return { + isUnlockingMessage: true, + unlockingMessageId: event.messageId, + unlockingMessageKind: event.kind, + unlockMessageError: null, + unlockPaywallRequest: null, + }; + }), + + applyUnlockMessageSucceeded: assign(({ context, event }) => { + const output = (event as { output?: UnlockMessageOutput }).output; + if (!output) return {}; + return { + messages: applySingleUnlockOutput(context.messages, output), + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: null, + unlockPaywallRequest: null, + creditBalance: output.response.creditBalance, + creditsCharged: output.response.creditsCharged, + requiredCredits: 0, + shortfallCredits: 0, + }; + }), + + requestUnlockPaymentFromOutput: assign(({ context, event }) => { + const output = (event as { output?: UnlockMessageOutput }).output; + if (!output) return {}; + return { + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: output.response.reason, + unlockPaywallRequest: { + messageId: output.messageId, + kind: context.unlockingMessageKind ?? "private", + reason: output.response.reason, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }; + }), + + requestUnlockPaymentFromError: assign(({ context }) => ({ + isUnlockingMessage: false, + unlockMessageError: "unlock_failed", + unlockPaywallRequest: + context.unlockingMessageId && context.unlockingMessageKind + ? { + messageId: context.unlockingMessageId, + kind: context.unlockingMessageKind, + reason: "unlock_failed", + creditBalance: context.creditBalance, + requiredCredits: context.requiredCredits, + shortfallCredits: context.shortfallCredits, + } + : null, + unlockingMessageId: null, + unlockingMessageKind: null, + })), + + clearUnlockPaywallRequest: assign(() => ({ + unlockPaywallRequest: null, + })), }, }).createMachine({ id: "chat", @@ -389,6 +465,9 @@ export const chatMachine = setup({ ChatUnlockHistoryDismissed: { actions: "dismissUnlockHistoryPrompt", }, + ChatUnlockPaywallNavigationConsumed: { + actions: "clearUnlockPaywallRequest", + }, }, initial: "initializing", states: { @@ -456,6 +535,11 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, + ChatUnlockMessageRequested: { + guard: ({ event }) => event.messageId.trim().length > 0, + target: "unlockingMessage", + actions: "markUnlockMessageStarted", + }, }, }, sendingViaHttp: { @@ -529,6 +613,30 @@ export const chatMachine = setup({ }, }, }, + unlockingMessage: { + invoke: { + id: "unlockMessage", + src: "unlockMessage", + input: ({ context }) => ({ + messageId: context.unlockingMessageId ?? "", + }), + onDone: [ + { + guard: ({ event }) => event.output.response.unlocked, + target: "ready", + actions: "applyUnlockMessageSucceeded", + }, + { + target: "ready", + actions: "requestUnlockPaymentFromOutput", + }, + ], + onError: { + target: "ready", + actions: "requestUnlockPaymentFromError", + }, + }, + }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 588baa21..0c19ca9d 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -1,7 +1,17 @@ import type { UiMessage } from "@/data/dto/chat"; +import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; export type ChatUpgradeReason = "weekly_limit" | "insufficient_credits"; +export interface ChatUnlockPaywallRequest { + messageId: string; + kind: PendingChatUnlockKind; + reason: string; + creditBalance: number; + requiredCredits: number; + shortfallCredits: number; +} + export interface ChatState { messages: UiMessage[]; isReplyingAI: boolean; @@ -27,6 +37,11 @@ export interface ChatState { lockedHistoryCount: number; isUnlockingHistory: boolean; unlockHistoryError: string | null; + isUnlockingMessage: boolean; + unlockingMessageId: string | null; + unlockingMessageKind: PendingChatUnlockKind | null; + unlockMessageError: string | null; + unlockPaywallRequest: ChatUnlockPaywallRequest | null; } export const initialState: ChatState = { @@ -50,4 +65,9 @@ export const initialState: ChatState = { lockedHistoryCount: 0, isUnlockingHistory: false, unlockHistoryError: null, + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: null, + unlockPaywallRequest: null, }; diff --git a/src/stores/payment/payment-success-sync.tsx b/src/stores/payment/payment-success-sync.tsx index 205b5ccc..2f5022e0 100644 --- a/src/stores/payment/payment-success-sync.tsx +++ b/src/stores/payment/payment-success-sync.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef } from "react"; import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; +import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; import { usePaymentState } from "./payment-context"; @@ -28,6 +29,7 @@ export function PaymentSuccessSync() { lastPaidKeyRef.current = paidKey; userDispatch({ type: "UserFetch" }); + if (hasPendingChatUnlock()) return; chatDispatch({ type: "ChatPaymentSucceeded" }); }, [ chatDispatch, diff --git a/src/stores/user/__tests__/user-machine.helpers.test.ts b/src/stores/user/__tests__/user-machine.helpers.test.ts index 8efc94ca..97571ed2 100644 --- a/src/stores/user/__tests__/user-machine.helpers.test.ts +++ b/src/stores/user/__tests__/user-machine.helpers.test.ts @@ -11,6 +11,8 @@ describe("toView", () => { id: "user-1", username: "Luna", email: "", + country: "", + countryCode: "", avatarUrl: "", intimacy: 0, dolBalance: 0, @@ -30,6 +32,8 @@ describe("toView", () => { id: "user-2", username: "Elio", email: "elio@example.com", + country: "Hong Kong", + countryCode: "HK", avatarUrl: "https://example.com/avatar.png", intimacy: 42, dolBalance: 12, @@ -45,6 +49,8 @@ describe("toView", () => { id: "user-2", username: "Elio", email: "elio@example.com", + country: "Hong Kong", + countryCode: "HK", avatarUrl: "https://example.com/avatar.png", intimacy: 42, dolBalance: 12,