From 61796c732f8f8dfe187e0bacbdf76156c5464a13 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 29 Jun 2026 16:19:48 +0800 Subject: [PATCH] feat(chat): restore image viewer after payment --- src/app/chat/chat-image-return-session.ts | 55 ++++++++++++ src/app/chat/chat-screen.tsx | 12 +-- src/app/chat/components/chat-area.tsx | 6 +- src/app/chat/components/image-bubble.tsx | 85 +++++++++---------- src/app/chat/components/message-bubble.tsx | 8 +- src/app/chat/components/message-content.tsx | 6 +- .../chat-image-viewer-screen.module.css | 32 +++++++ .../[messageId]/chat-image-viewer-screen.tsx | 72 ++++++++++++++++ src/app/chat/image/[messageId]/page.tsx | 8 ++ src/router/routes.ts | 7 +- .../__tests__/chat-machine.helpers.test.ts | 11 ++- .../chat-machine.transitions.test.ts | 58 +++++++++++++ 12 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 src/app/chat/chat-image-return-session.ts create mode 100644 src/app/chat/image/[messageId]/chat-image-viewer-screen.module.css create mode 100644 src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx create mode 100644 src/app/chat/image/[messageId]/page.tsx diff --git a/src/app/chat/chat-image-return-session.ts b/src/app/chat/chat-image-return-session.ts new file mode 100644 index 00000000..e4faf014 --- /dev/null +++ b/src/app/chat/chat-image-return-session.ts @@ -0,0 +1,55 @@ +"use client"; + +const STORAGE_KEY = "cozsweet.chat.pendingImageReturn"; +const MAX_AGE_MS = 30 * 60 * 1000; + +export interface PendingChatImageReturn { + reason: "image_paywall"; + messageId: string; + returnUrl: string; + createdAt: number; +} + +export function savePendingChatImageReturn(input: { + messageId: string; + returnUrl: string; +}): void { + if (typeof window === "undefined") return; + const payload: PendingChatImageReturn = { + reason: "image_paywall", + messageId: input.messageId, + returnUrl: input.returnUrl, + createdAt: Date.now(), + }; + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); +} + +export function consumePendingChatImageReturn(): PendingChatImageReturn | null { + if (typeof window === "undefined") return null; + + const raw = window.sessionStorage.getItem(STORAGE_KEY); + window.sessionStorage.removeItem(STORAGE_KEY); + if (!raw) return null; + + try { + const value = JSON.parse(raw) as Partial; + if (value.reason !== "image_paywall") return null; + if (typeof value.messageId !== "string" || value.messageId.length === 0) { + return null; + } + if (typeof value.returnUrl !== "string" || value.returnUrl.length === 0) { + 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, + returnUrl: value.returnUrl, + createdAt: value.createdAt, + }; + } catch { + return null; + } +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index e69ff14e..7cad1c97 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -8,6 +8,7 @@ import { useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { MobileShell } from "@/app/_components/core"; +import { consumePendingChatImageReturn } from "./chat-image-return-session"; import { BrowserHintOverlay, @@ -88,6 +89,12 @@ export function ChatScreen() { authState.loginStatus, ]); + useEffect(() => { + const pendingImageReturn = consumePendingChatImageReturn(); + if (!pendingImageReturn) return; + router.replace(pendingImageReturn.returnUrl); + }, [router]); + async function handleOpenExternalBrowser(): Promise { setShowExternalBrowserDialog(false); await openChatInExternalBrowser(); @@ -101,10 +108,6 @@ export function ChatScreen() { openChatPaywallSubscription(); } - function handleUnlockImagePaywall(): void { - openChatPaywallSubscription(); - } - function handleMessageLimitUnlock(): void { openChatPaywallSubscription(); } @@ -135,7 +138,6 @@ export function ChatScreen() { isReplyingAI={state.isReplyingAI} isGuest={isGuest} onUnlockPrivateMessage={handleUnlockPrivateMessage} - onUnlockImagePaywall={handleUnlockImagePaywall} onUnlockVoiceMessage={handleUnlockVoiceMessage} /> diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index cb4cdf67..c26d237f 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -27,7 +27,6 @@ export interface ChatAreaProps { isReplyingAI: boolean; isGuest: boolean; onUnlockPrivateMessage?: () => void; - onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } @@ -35,7 +34,6 @@ export function ChatArea({ messages, isReplyingAI, onUnlockPrivateMessage, - onUnlockImagePaywall, onUnlockVoiceMessage, }: ChatAreaProps) { const scrollRef = useRef(null); @@ -59,7 +57,6 @@ export function ChatArea({ {renderMessagesWithDateHeaders( messages, onUnlockPrivateMessage, - onUnlockImagePaywall, onUnlockVoiceMessage, )} @@ -72,7 +69,6 @@ export function ChatArea({ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], onUnlockPrivateMessage?: () => void, - onUnlockImagePaywall?: () => void, onUnlockVoiceMessage?: () => void, ) { const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; @@ -90,6 +86,7 @@ function renderMessagesWithDateHeaders( ) : ( ), diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index bb093830..8cf23a06 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -4,73 +4,70 @@ * * 支持: * - base64 data URI 解码(`data:image/png;base64,...`) - * - 点击 → 打开全屏查看器 + * - 点击 → 跳转动态路由打开全屏查看器 * - 错误占位符(broken image icon) */ import Image from "next/image"; +import { useRouter } from "next/navigation"; import { useState } from "react"; -import { FullscreenImageViewer } from "./fullscreen-image-viewer"; +import { ROUTE_BUILDERS } from "@/router/routes"; + import styles from "./image-bubble.module.css"; export interface ImageBubbleProps { + messageId?: string; imageUrl: string; // base64 data URI 或 URL imagePaywalled?: boolean; - onUnlockImagePaywall?: () => void; } export function ImageBubble({ + messageId, imageUrl, imagePaywalled = false, - onUnlockImagePaywall, }: ImageBubbleProps) { - const [open, setOpen] = useState(false); + const router = useRouter(); const [error, setError] = useState(false); const src = decodeBase64Image(imageUrl); + const canOpen = Boolean(messageId); + + const openImage = () => { + if (!messageId) return; + router.push(ROUTE_BUILDERS.chatImage(messageId)); + }; return ( - <> -
setOpen(true)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setOpen(true); - } - }} - aria-label="Open image in fullscreen" - > - {error ? ( -
🖼
- ) : ( - setError(true)} - /> - )} - {!error && imagePaywalled ? ( - - {open && ( - setOpen(false)} +
{ + if (!canOpen) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openImage(); + } + }} + aria-label={canOpen ? "Open image in fullscreen" : undefined} + > + {error ? ( +
🖼
+ ) : ( + setError(true)} /> )} - + {!error && imagePaywalled ? ( + ); } diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 6d6b8028..aa2a8e9f 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -15,6 +15,7 @@ import { MessageContent } from "./message-content"; import styles from "./chat-area.module.css"; export interface MessageBubbleProps { + messageId?: string; content: string; imageUrl?: string | null; imagePaywalled?: boolean; @@ -25,11 +26,11 @@ export interface MessageBubbleProps { lockedPrivate?: boolean | null; privateMessageHint?: string | null; onUnlockPrivateMessage?: () => void; - onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } export function MessageBubble({ + messageId, content, imageUrl, imagePaywalled, @@ -40,7 +41,6 @@ export function MessageBubble({ lockedPrivate, privateMessageHint, onUnlockPrivateMessage, - onUnlockImagePaywall, onUnlockVoiceMessage, }: MessageBubbleProps) { const { avatarUrl } = useUserState(); @@ -55,13 +55,13 @@ export function MessageBubble({ imageUrl={imageUrl} imagePaywalled={imagePaywalled} audioUrl={audioUrl} + messageId={messageId} isFromAI={true} locked={locked} lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} onUnlockPrivateMessage={onUnlockPrivateMessage} - onUnlockImagePaywall={onUnlockImagePaywall} onUnlockVoiceMessage={onUnlockVoiceMessage} />
{/* 占位:保持与头像宽度一致 */} @@ -77,13 +77,13 @@ export function MessageBubble({ imageUrl={imageUrl} imagePaywalled={imagePaywalled} audioUrl={audioUrl} + messageId={messageId} isFromAI={false} locked={locked} lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} onUnlockPrivateMessage={onUnlockPrivateMessage} - onUnlockImagePaywall={onUnlockImagePaywall} onUnlockVoiceMessage={onUnlockVoiceMessage} />
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 876839af..045acf02 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -9,13 +9,13 @@ export interface MessageContentProps { imageUrl?: string | null; imagePaywalled?: boolean; audioUrl?: string | null; + messageId?: string; isFromAI: boolean; locked?: boolean | null; lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; onUnlockPrivateMessage?: () => void; - onUnlockImagePaywall?: () => void; onUnlockVoiceMessage?: () => void; } @@ -26,13 +26,13 @@ export function MessageContent({ imageUrl, imagePaywalled, audioUrl, + messageId, isFromAI, locked, lockReason, lockedPrivate, privateMessageHint, onUnlockPrivateMessage, - onUnlockImagePaywall, onUnlockVoiceMessage, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; @@ -66,9 +66,9 @@ export function MessageContent({ <> {hasImage && imageUrl && ( )} {hasAudio && audioUrl ? ( diff --git a/src/app/chat/image/[messageId]/chat-image-viewer-screen.module.css b/src/app/chat/image/[messageId]/chat-image-viewer-screen.module.css new file mode 100644 index 00000000..34cf827e --- /dev/null +++ b/src/app/chat/image/[messageId]/chat-image-viewer-screen.module.css @@ -0,0 +1,32 @@ +.emptyScreen { + display: flex; + min-height: 100dvh; + box-sizing: border-box; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 24px; + background: #0d0b14; + color: #ffffff; +} + +.backButton { + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #191316; + cursor: pointer; + font: inherit; + font-size: 15px; + font-weight: 800; +} + +.emptyText { + margin: 0; + color: rgba(255, 255, 255, 0.78); + font-size: 16px; + font-weight: 700; +} diff --git a/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx new file mode 100644 index 00000000..d4c0223b --- /dev/null +++ b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; +import { useAuthState } from "@/stores/auth/auth-context"; +import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; + +import { getChatPaywallNavigationUrl } from "../../chat-screen.helpers"; +import { savePendingChatImageReturn } from "../../chat-image-return-session"; +import { FullscreenImageViewer, HistoryUnlockDialog } from "../../components"; +import styles from "./chat-image-viewer-screen.module.css"; + +export interface ChatImageViewerScreenProps { + messageId: string; +} + +export function ChatImageViewerScreen({ + messageId, +}: ChatImageViewerScreenProps) { + const router = useRouter(); + const authState = useAuthState(); + const chatState = useChatState(); + const chatDispatch = useChatDispatch(); + const message = chatState.messages.find( + (item) => item.id === messageId && item.imageUrl, + ); + + const handleClose = () => { + router.push(ROUTES.chat); + }; + + const handleUnlockImagePaywall = () => { + savePendingChatImageReturn({ + messageId, + returnUrl: ROUTE_BUILDERS.chatImage(messageId), + }); + router.push(getChatPaywallNavigationUrl(authState.loginStatus)); + }; + + if (!message) { + return ( +
+ +

+ {chatState.historyLoaded ? "Image not found." : "Loading image..."} +

+
+ ); + } + + return ( + <> + + chatDispatch({ type: "ChatUnlockHistoryDismissed" })} + onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} + /> + + ); +} diff --git a/src/app/chat/image/[messageId]/page.tsx b/src/app/chat/image/[messageId]/page.tsx new file mode 100644 index 00000000..db3430db --- /dev/null +++ b/src/app/chat/image/[messageId]/page.tsx @@ -0,0 +1,8 @@ +import { ChatImageViewerScreen } from "./chat-image-viewer-screen"; + +export default async function ChatImagePage( + props: PageProps<"/chat/image/[messageId]">, +) { + const { messageId } = await props.params; + return ; +} diff --git a/src/router/routes.ts b/src/router/routes.ts index 2933c900..4102614d 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -31,6 +31,8 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES]; export const ROUTE_BUILDERS = { chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` => `/chat/deviceid/${encodeURIComponent(deviceId)}` as const, + chatImage: (messageId: string): `/chat/image/${string}` => + `/chat/image/${encodeURIComponent(messageId)}` as const, subscription: ( type: "vip" | "topup", options: { returnTo?: "chat" } = {}, @@ -73,4 +75,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [ ] as const; /** 联合路由类型,包含静态路由与已知动态路由 */ -export type Route = StaticRoute | `/chat/deviceid/${string}`; +export type Route = + | StaticRoute + | `/chat/deviceid/${string}` + | `/chat/image/${string}`; diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 3e60818a..b9297a0f 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -256,6 +256,15 @@ describe("countLockedHistoryMessages", () => { locked: true, lockReason: "voice_message", }, + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + imageUrl: "https://example.com/locked.jpg", + imagePaywalled: true, + lockReason: "image", + }, { content: "", isFromAI: true, @@ -271,6 +280,6 @@ describe("countLockedHistoryMessages", () => { lockReason: "private_message", }, ]), - ).toBe(2); + ).toBe(3); }); }); diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index eed2dcd4..f60ed230 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -322,6 +322,64 @@ describe("chatMachine transitions", () => { actor.stop(); }); + it("auto unlocks a single locked image message after payment", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + id: "msg-image-locked", + content: "", + isFromAI: true, + date: "2026-06-29", + imageUrl: "https://example.com/locked.jpg", + imagePaywalled: true, + locked: true, + lockReason: "image", + }, + ], + unlockHistoryOutput: { + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [ + { + id: "msg-image-locked", + content: "", + isFromAI: true, + date: "2026-06-29", + imageUrl: "https://example.com/unlocked.jpg", + 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" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { + id: "msg-image-locked", + imageUrl: "https://example.com/unlocked.jpg", + locked: false, + }, + ]); + + actor.stop(); + }); + it("unlocks history after the prompt is confirmed", async () => { const actor = createActor( createTestChatMachine({