feat(chat): restore image viewer after payment

This commit is contained in:
2026-06-29 16:19:48 +08:00
parent 6273cd1bbe
commit 61796c732f
12 changed files with 297 additions and 63 deletions
+55
View File
@@ -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;
}
}
+7 -5
View File
@@ -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}
/>
+1 -5
View File
@@ -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}
/>
),
+41 -44
View File
@@ -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>
);
}
+4 -4
View File
@@ -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 }} />
+3 -3
View File
@@ -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" })}
/>
</>
);
}
+8
View File
@@ -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} />;
}