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 { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
import { consumePendingChatImageReturn } from "./chat-image-return-session";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BrowserHintOverlay,
|
BrowserHintOverlay,
|
||||||
@@ -88,6 +89,12 @@ export function ChatScreen() {
|
|||||||
authState.loginStatus,
|
authState.loginStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pendingImageReturn = consumePendingChatImageReturn();
|
||||||
|
if (!pendingImageReturn) return;
|
||||||
|
router.replace(pendingImageReturn.returnUrl);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
async function handleOpenExternalBrowser(): Promise<void> {
|
async function handleOpenExternalBrowser(): Promise<void> {
|
||||||
setShowExternalBrowserDialog(false);
|
setShowExternalBrowserDialog(false);
|
||||||
await openChatInExternalBrowser();
|
await openChatInExternalBrowser();
|
||||||
@@ -101,10 +108,6 @@ export function ChatScreen() {
|
|||||||
openChatPaywallSubscription();
|
openChatPaywallSubscription();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockImagePaywall(): void {
|
|
||||||
openChatPaywallSubscription();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMessageLimitUnlock(): void {
|
function handleMessageLimitUnlock(): void {
|
||||||
openChatPaywallSubscription();
|
openChatPaywallSubscription();
|
||||||
}
|
}
|
||||||
@@ -135,7 +138,6 @@ export function ChatScreen() {
|
|||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
|
||||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface ChatAreaProps {
|
|||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
onUnlockPrivateMessage?: () => void;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@ export function ChatArea({
|
|||||||
messages,
|
messages,
|
||||||
isReplyingAI,
|
isReplyingAI,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -59,7 +57,6 @@ export function ChatArea({
|
|||||||
{renderMessagesWithDateHeaders(
|
{renderMessagesWithDateHeaders(
|
||||||
messages,
|
messages,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -72,7 +69,6 @@ export function ChatArea({
|
|||||||
function renderMessagesWithDateHeaders(
|
function renderMessagesWithDateHeaders(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
onUnlockPrivateMessage?: () => void,
|
onUnlockPrivateMessage?: () => void,
|
||||||
onUnlockImagePaywall?: () => void,
|
|
||||||
onUnlockVoiceMessage?: () => void,
|
onUnlockVoiceMessage?: () => void,
|
||||||
) {
|
) {
|
||||||
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
||||||
@@ -90,6 +86,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
messageId={item.message.id}
|
||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
imageUrl={item.message.imageUrl}
|
imageUrl={item.message.imageUrl}
|
||||||
imagePaywalled={item.message.imagePaywalled}
|
imagePaywalled={item.message.imagePaywalled}
|
||||||
@@ -100,7 +97,6 @@ function renderMessagesWithDateHeaders(
|
|||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,47 +4,53 @@
|
|||||||
*
|
*
|
||||||
* 支持:
|
* 支持:
|
||||||
* - base64 data URI 解码(`data:image/png;base64,...`)
|
* - base64 data URI 解码(`data:image/png;base64,...`)
|
||||||
* - 点击 → 打开全屏查看器
|
* - 点击 → 跳转动态路由打开全屏查看器
|
||||||
* - 错误占位符(broken image icon)
|
* - 错误占位符(broken image icon)
|
||||||
*/
|
*/
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { FullscreenImageViewer } from "./fullscreen-image-viewer";
|
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||||
|
|
||||||
import styles from "./image-bubble.module.css";
|
import styles from "./image-bubble.module.css";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
|
messageId?: string;
|
||||||
imageUrl: string; // base64 data URI 或 URL
|
imageUrl: string; // base64 data URI 或 URL
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
onUnlockImagePaywall?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageBubble({
|
export function ImageBubble({
|
||||||
|
messageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
onUnlockImagePaywall,
|
|
||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const src = decodeBase64Image(imageUrl);
|
const src = decodeBase64Image(imageUrl);
|
||||||
|
const canOpen = Boolean(messageId);
|
||||||
|
|
||||||
|
const openImage = () => {
|
||||||
|
if (!messageId) return;
|
||||||
|
router.push(ROUTE_BUILDERS.chatImage(messageId));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.bubble} ${
|
className={`${styles.bubble} ${imagePaywalled ? styles.paywalled : ""}`}
|
||||||
imagePaywalled ? styles.paywalled : ""
|
onClick={openImage}
|
||||||
}`}
|
role={canOpen ? "button" : undefined}
|
||||||
onClick={() => setOpen(true)}
|
tabIndex={canOpen ? 0 : undefined}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (!canOpen) return;
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen(true);
|
openImage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Open image in fullscreen"
|
aria-label={canOpen ? "Open image in fullscreen" : undefined}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className={styles.errorFallback}>🖼</div>
|
<div className={styles.errorFallback}>🖼</div>
|
||||||
@@ -62,15 +68,6 @@ export function ImageBubble({
|
|||||||
<div className={styles.paywallOverlay} aria-hidden="true" />
|
<div className={styles.paywallOverlay} aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
|
||||||
<FullscreenImageViewer
|
|
||||||
imageUrl={imageUrl}
|
|
||||||
imagePaywalled={imagePaywalled}
|
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MessageContent } from "./message-content";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
|
messageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -25,11 +26,11 @@ export interface MessageBubbleProps {
|
|||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
onUnlockPrivateMessage?: () => void;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
|
messageId,
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
@@ -40,7 +41,6 @@ export function MessageBubble({
|
|||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { avatarUrl } = useUserState();
|
const { avatarUrl } = useUserState();
|
||||||
@@ -55,13 +55,13 @@ export function MessageBubble({
|
|||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
|
messageId={messageId}
|
||||||
isFromAI={true}
|
isFromAI={true}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||||
@@ -77,13 +77,13 @@ export function MessageBubble({
|
|||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
|
messageId={messageId}
|
||||||
isFromAI={false}
|
isFromAI={false}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export interface MessageContentProps {
|
|||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
|
messageId?: string;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
locked?: boolean | null;
|
locked?: boolean | null;
|
||||||
lockReason?: string | null;
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
onUnlockPrivateMessage?: () => void;
|
onUnlockPrivateMessage?: () => void;
|
||||||
onUnlockImagePaywall?: () => void;
|
|
||||||
onUnlockVoiceMessage?: () => void;
|
onUnlockVoiceMessage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +26,13 @@ export function MessageContent({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
|
messageId,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
locked,
|
locked,
|
||||||
lockReason,
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockImagePaywall,
|
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
@@ -66,9 +66,9 @@ export function MessageContent({
|
|||||||
<>
|
<>
|
||||||
{hasImage && imageUrl && (
|
{hasImage && imageUrl && (
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
|
messageId={messageId}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasAudio && audioUrl ? (
|
{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 = {
|
export const ROUTE_BUILDERS = {
|
||||||
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||||
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||||
|
chatImage: (messageId: string): `/chat/image/${string}` =>
|
||||||
|
`/chat/image/${encodeURIComponent(messageId)}` as const,
|
||||||
subscription: (
|
subscription: (
|
||||||
type: "vip" | "topup",
|
type: "vip" | "topup",
|
||||||
options: { returnTo?: "chat" } = {},
|
options: { returnTo?: "chat" } = {},
|
||||||
@@ -73,4 +75,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
|||||||
] as const;
|
] 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,
|
locked: true,
|
||||||
lockReason: "voice_message",
|
lockReason: "voice_message",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "",
|
||||||
|
isFromAI: true,
|
||||||
|
date: "2026-06-29",
|
||||||
|
locked: true,
|
||||||
|
imageUrl: "https://example.com/locked.jpg",
|
||||||
|
imagePaywalled: true,
|
||||||
|
lockReason: "image",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
@@ -271,6 +280,6 @@ describe("countLockedHistoryMessages", () => {
|
|||||||
lockReason: "private_message",
|
lockReason: "private_message",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toBe(2);
|
).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -322,6 +322,64 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.stop();
|
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 () => {
|
it("unlocks history after the prompt is confirmed", async () => {
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
|
|||||||
Reference in New Issue
Block a user