feat(chat): restore image viewer after payment
This commit is contained in:
@@ -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<PendingChatImageReturn>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(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(
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={item.key}
|
||||
messageId={item.message.id}
|
||||
content={item.message.content}
|
||||
imageUrl={item.message.imageUrl}
|
||||
imagePaywalled={item.message.imagePaywalled}
|
||||
@@ -100,7 +97,6 @@ function renderMessagesWithDateHeaders(
|
||||
lockedPrivate={item.message.lockedPrivate}
|
||||
privateMessageHint={item.message.privateMessageHint}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.bubble} ${
|
||||
imagePaywalled ? styles.paywalled : ""
|
||||
}`}
|
||||
onClick={() => 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 ? (
|
||||
<div className={styles.errorFallback}>🖼</div>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt=""
|
||||
width={240}
|
||||
height={240}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)}
|
||||
{!error && imagePaywalled ? (
|
||||
<div className={styles.paywallOverlay} aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
{open && (
|
||||
<FullscreenImageViewer
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||
onClose={() => setOpen(false)}
|
||||
<div
|
||||
className={`${styles.bubble} ${imagePaywalled ? styles.paywalled : ""}`}
|
||||
onClick={openImage}
|
||||
role={canOpen ? "button" : undefined}
|
||||
tabIndex={canOpen ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (!canOpen) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openImage();
|
||||
}
|
||||
}}
|
||||
aria-label={canOpen ? "Open image in fullscreen" : undefined}
|
||||
>
|
||||
{error ? (
|
||||
<div className={styles.errorFallback}>🖼</div>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt=""
|
||||
width={240}
|
||||
height={240}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{!error && imagePaywalled ? (
|
||||
<div className={styles.paywallOverlay} aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||
@@ -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}
|
||||
/>
|
||||
<div style={{ width: 8 }} />
|
||||
|
||||
@@ -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 && (
|
||||
<ImageBubble
|
||||
messageId={messageId}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
||||
/>
|
||||
)}
|
||||
{hasAudio && audioUrl ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<main className={styles.emptyScreen}>
|
||||
<button type="button" className={styles.backButton} onClick={handleClose}>
|
||||
Back to chat
|
||||
</button>
|
||||
<p className={styles.emptyText}>
|
||||
{chatState.historyLoaded ? "Image not found." : "Loading image..."}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FullscreenImageViewer
|
||||
imageUrl={message.imageUrl ?? ""}
|
||||
imagePaywalled={message.imagePaywalled === true}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<HistoryUnlockDialog
|
||||
open={chatState.unlockHistoryPromptVisible}
|
||||
lockedCount={chatState.lockedHistoryCount}
|
||||
isLoading={chatState.isUnlockingHistory}
|
||||
errorMessage={chatState.unlockHistoryError}
|
||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 <ChatImageViewerScreen messageId={messageId} />;
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user