refactor(chat): show images in page overlay

This commit is contained in:
2026-07-09 10:11:25 +08:00
parent cb7791dd8d
commit fc92c3a5d5
23 changed files with 253 additions and 627 deletions
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
buildChatImageOverlayUrl,
buildChatWithoutImageOverlayUrl,
getChatImageOverlayMessageId,
} from "../chat-image-overlay-url";
describe("chat image overlay url helpers", () => {
it("reads a non-empty image message id from search params", () => {
expect(
getChatImageOverlayMessageId(new URLSearchParams("image=msg_1")),
).toBe("msg_1");
});
it("ignores missing or blank image message ids", () => {
expect(getChatImageOverlayMessageId(new URLSearchParams(""))).toBeNull();
expect(
getChatImageOverlayMessageId(new URLSearchParams("image=%20")),
).toBeNull();
});
it("builds the chat image overlay url", () => {
expect(buildChatImageOverlayUrl("msg 1")).toBe("/chat?image=msg+1");
});
it("removes only the image param when closing the overlay", () => {
expect(
buildChatWithoutImageOverlayUrl(
new URLSearchParams("image=msg_1&foo=bar"),
),
).toBe("/chat?foo=bar");
expect(
buildChatWithoutImageOverlayUrl(new URLSearchParams("image=msg_1")),
).toBe("/chat");
});
});
@@ -1,116 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { ChatScrollSessionStorage } from "@/lib/chat/chat_scroll_session_storage";
import {
consumeChatScrollSnapshot,
getChatScrollRestoreTop,
saveChatScrollSnapshot,
} from "../chat-scroll-session";
afterEach(async () => {
await consumeChatScrollSnapshot();
await ChatScrollSessionStorage.clearSnapshot();
document.body.innerHTML = "";
});
describe("chat scroll session", () => {
it("saves image viewer scroll sessions by message anchor", async () => {
const { anchorNode, scrollNode } = createScrollFixture({
anchorTop: 220,
scrollTop: 120,
});
saveChatScrollSnapshot(scrollNode, { anchorMessageId: "msg-image-1" });
const snapshot = await consumeChatScrollSnapshot();
expect(snapshot).toEqual({
version: 2,
reason: "image-viewer",
anchorMessageId: "msg-image-1",
anchorOffsetTop: 120,
fallbackScrollTop: 120,
savedAt: expect.any(Number),
});
expect(anchorNode.dataset.chatMessageId).toBe("msg-image-1");
});
it("restores scroll top by keeping the anchor at the saved visual offset", () => {
const { scrollNode } = createScrollFixture({
anchorTop: 250,
scrollTop: 200,
});
expect(
getChatScrollRestoreTop(scrollNode, {
version: 2,
reason: "image-viewer",
anchorMessageId: "msg-image-1",
anchorOffsetTop: 120,
fallbackScrollTop: 120,
savedAt: Date.now(),
}),
).toBe(230);
});
it("falls back to the saved scroll top when the anchor is missing", () => {
const { scrollNode } = createScrollFixture({
anchorTop: 250,
scrollTop: 200,
});
expect(
getChatScrollRestoreTop(scrollNode, {
version: 2,
reason: "image-viewer",
anchorMessageId: "missing-message",
anchorOffsetTop: 120,
fallbackScrollTop: 180,
savedAt: Date.now(),
}),
).toBe(180);
});
});
function createScrollFixture(input: {
anchorTop: number;
scrollTop: number;
}): {
anchorNode: HTMLElement;
scrollNode: HTMLElement;
} {
const scrollNode = document.createElement("div");
Object.defineProperties(scrollNode, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1000 },
scrollTop: {
configurable: true,
value: input.scrollTop,
writable: true,
},
});
scrollNode.getBoundingClientRect = () => makeRect(100);
const anchorNode = document.createElement("div");
anchorNode.dataset.chatMessageId = "msg-image-1";
anchorNode.getBoundingClientRect = () => makeRect(input.anchorTop);
scrollNode.appendChild(anchorNode);
document.body.appendChild(scrollNode);
return { anchorNode, scrollNode };
}
function makeRect(top: number): DOMRect {
return {
bottom: top + 40,
height: 40,
left: 0,
right: 100,
toJSON: () => ({}),
top,
width: 100,
x: 0,
y: top,
};
}
+26
View File
@@ -0,0 +1,26 @@
import { ROUTES } from "@/router/routes";
export const CHAT_IMAGE_QUERY_PARAM = "image";
export function getChatImageOverlayMessageId(input: {
get: (key: string) => string | null;
}): string | null {
const value = input.get(CHAT_IMAGE_QUERY_PARAM)?.trim();
return value && value.length > 0 ? value : null;
}
export function buildChatImageOverlayUrl(messageId: string): `/chat?${string}` {
const params = new URLSearchParams({
[CHAT_IMAGE_QUERY_PARAM]: messageId,
});
return `${ROUTES.chat}?${params.toString()}` as const;
}
export function buildChatWithoutImageOverlayUrl(input: {
toString: () => string;
}): string {
const params = new URLSearchParams(input.toString());
params.delete(CHAT_IMAGE_QUERY_PARAM);
const query = params.toString();
return query ? `${ROUTES.chat}?${query}` : ROUTES.chat;
}
+90 -1
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useMemo } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatState } from "@/stores/chat/chat-context";
@@ -17,8 +19,14 @@ import {
ChatUnlockDialogs,
ExternalBrowserDialog,
FirstRechargeOfferBanner,
FullscreenImageViewer,
PwaInstallOverlay,
} from "./components";
import {
buildChatImageOverlayUrl,
buildChatWithoutImageOverlayUrl,
getChatImageOverlayMessageId,
} from "./chat-image-overlay-url";
import {
deriveIsGuest,
isChatDevelopmentEnvironment,
@@ -31,15 +39,44 @@ import { useExternalBrowserPrompt } from "./hooks/use-external-browser-prompt";
import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const state = useChatState();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const imageMessageId = getChatImageOverlayMessageId(searchParams);
const imageReturnUrl = imageMessageId
? buildChatImageOverlayUrl(imageMessageId)
: ROUTES.chat;
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
} = useChatUnlockNavigationFlow({
returnUrl: ROUTES.chat,
ignoredKind: "image",
});
const {
unlockPaywallRequest: imageUnlockPaywallRequest,
requestMessageUnlock: requestImageUnlock,
closeInsufficientCreditsDialog: closeImageInsufficientCreditsDialog,
confirmInsufficientCreditsDialog: confirmImageInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({
returnUrl: imageReturnUrl,
expectedKind: "image",
expectedMessageId: imageMessageId ?? undefined,
enabled: imageMessageId !== null,
});
const selectedImageMessage = useMemo(
() =>
imageMessageId
? state.messages.find(
(item) => item.id === imageMessageId && item.imageUrl,
) ?? null
: null,
[imageMessageId, state.messages],
);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
@@ -68,6 +105,21 @@ export function ChatScreen() {
loginStatus: authState.loginStatus,
});
useEffect(() => {
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
return;
}
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
scroll: false,
});
}, [
imageMessageId,
router,
searchParams,
selectedImageMessage,
state.historyLoaded,
]);
function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private");
}
@@ -76,6 +128,21 @@ export function ChatScreen() {
requestMessageUnlock(messageId, "voice");
}
function handleOpenImage(messageId: string): void {
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
}
function handleCloseImageViewer(): void {
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
scroll: false,
});
}
function handleUnlockImagePaywall(): void {
if (!imageMessageId) return;
requestImageUnlock(imageMessageId, "image");
}
return (
<MobileShell>
<div className={styles.shell}>
@@ -110,6 +177,7 @@ export function ChatScreen() {
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
onOpenImage={handleOpenImage}
/>
{messageLimitBanner.visible ? (
@@ -141,6 +209,27 @@ export function ChatScreen() {
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
<ChatUnlockDialogs
unlockPaywallRequest={imageUnlockPaywallRequest}
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={
confirmImageInsufficientCreditsDialog
}
/>
{selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer
messageId={selectedImageMessage.id}
imageUrl={selectedImageMessage.imageUrl}
imagePaywalled={selectedImageMessage.imagePaywalled === true}
isUnlockingImagePaywall={
state.isUnlockingMessage &&
state.unlockingMessageId === selectedImageMessage.id
}
onUnlockImagePaywall={handleUnlockImagePaywall}
onClose={handleCloseImageViewer}
/>
) : null}
</div>
</MobileShell>
);
-163
View File
@@ -1,163 +0,0 @@
"use client";
import {
ChatScrollSessionStorage,
type ChatScrollSession,
} from "@/lib/chat/chat_scroll_session_storage";
import { Result } from "@/utils";
const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll";
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
let memoryScrollSession: ChatScrollSession | null = null;
interface ChatScrollSnapshotSaveDetail {
anchorMessageId: string;
}
export function requestChatScrollSnapshotSave(anchorMessageId: string): void {
if (typeof window === "undefined") return;
if (anchorMessageId.trim().length === 0) return;
window.dispatchEvent(
new CustomEvent<ChatScrollSnapshotSaveDetail>(CHAT_SCROLL_SAVE_EVENT, {
detail: { anchorMessageId },
}),
);
}
export function listenChatScrollSnapshotSave(
listener: (detail: ChatScrollSnapshotSaveDetail) => void,
): () => void {
if (typeof window === "undefined") return () => {};
const handler = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as Partial<ChatScrollSnapshotSaveDetail>;
if (typeof detail.anchorMessageId !== "string") return;
listener({ anchorMessageId: detail.anchorMessageId });
};
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
}
export function saveChatScrollSnapshot(
scrollNode: HTMLElement,
detail: ChatScrollSnapshotSaveDetail,
): void {
if (typeof window === "undefined") return;
const { scrollTop } = scrollNode;
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
const anchorNode = findChatMessageElement(
scrollNode,
detail.anchorMessageId,
);
if (!anchorNode) return;
const payload: ChatScrollSession = {
version: 2,
reason: "image-viewer",
anchorMessageId: detail.anchorMessageId,
anchorOffsetTop: getElementOffsetTop(scrollNode, anchorNode),
fallbackScrollTop: scrollTop,
savedAt: Date.now(),
};
memoryScrollSession = payload;
void ChatScrollSessionStorage.setSnapshot(payload);
}
export async function consumeChatScrollSnapshot(): Promise<ChatScrollSession | null> {
if (typeof window === "undefined") return null;
const memoryPayload = readValidChatScrollSession(memoryScrollSession);
memoryScrollSession = null;
if (memoryPayload !== null) {
void ChatScrollSessionStorage.clearSnapshot();
return memoryPayload;
}
const storageResult = await ChatScrollSessionStorage.consumeSnapshot();
if (Result.isErr(storageResult)) return null;
return readValidChatScrollSession(storageResult.data);
}
export function getChatScrollRestoreTop(
scrollNode: HTMLElement,
snapshot: ChatScrollSession,
): number {
const anchorNode = findChatMessageElement(
scrollNode,
snapshot.anchorMessageId,
);
if (anchorNode) {
const currentOffsetTop = getElementOffsetTop(scrollNode, anchorNode);
return clampScrollTop(
scrollNode,
scrollNode.scrollTop + currentOffsetTop - snapshot.anchorOffsetTop,
);
}
return clampScrollTop(scrollNode, snapshot.fallbackScrollTop);
}
function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
if (typeof value !== "object" || value === null) return null;
const payload = value as Partial<ChatScrollSession>;
if (
payload.version !== 2 ||
payload.reason !== "image-viewer" ||
typeof payload.anchorMessageId !== "string" ||
payload.anchorMessageId.length === 0 ||
typeof payload.anchorOffsetTop !== "number" ||
!Number.isFinite(payload.anchorOffsetTop) ||
typeof payload.fallbackScrollTop !== "number" ||
!Number.isFinite(payload.fallbackScrollTop) ||
typeof payload.savedAt !== "number" ||
!Number.isFinite(payload.savedAt)
) {
return null;
}
if (Date.now() - payload.savedAt > CHAT_SCROLL_MAX_AGE_MS) {
return null;
}
return {
version: 2,
reason: "image-viewer",
anchorMessageId: payload.anchorMessageId,
anchorOffsetTop: payload.anchorOffsetTop,
fallbackScrollTop: Math.max(0, payload.fallbackScrollTop),
savedAt: payload.savedAt,
};
}
function findChatMessageElement(
scrollNode: HTMLElement,
messageId: string,
): HTMLElement | null {
return (
Array.from(
scrollNode.querySelectorAll<HTMLElement>("[data-chat-message-id]"),
).find((element) => element.dataset.chatMessageId === messageId) ?? null
);
}
function getElementOffsetTop(
scrollNode: HTMLElement,
element: HTMLElement,
): number {
return (
element.getBoundingClientRect().top -
scrollNode.getBoundingClientRect().top
);
}
function clampScrollTop(scrollNode: HTMLElement, value: number): number {
const maxScrollTop = Math.max(
0,
scrollNode.scrollHeight - scrollNode.clientHeight,
);
return Math.min(Math.max(0, value), maxScrollTop);
}
+15 -125
View File
@@ -16,12 +16,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { UiMessage } from "@/data/dto/chat";
import {
consumeChatScrollSnapshot,
getChatScrollRestoreTop,
listenChatScrollSnapshotSave,
saveChatScrollSnapshot,
} from "../chat-scroll-session";
import {
buildChatRenderItems,
createChatMessageKeyResolver,
@@ -35,8 +29,6 @@ import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
@@ -44,6 +36,7 @@ export interface ChatAreaProps {
unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
export function ChatArea({
@@ -53,30 +46,31 @@ export function ChatArea({
unlockingMessageId,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onOpenImage,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const restoredScrollRef = useRef(false);
const restoreStateRef = useRef<RestoreState>("idle");
const initialScrollSettledRef = useRef(false);
const wasNearBottomRef = useRef(true);
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
useLayoutEffect(() => {
if (initialScrollSettledRef.current || messages.length === 0) return;
const scrollNode = scrollRef.current;
if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode, { anchorMessageId });
};
initialScrollSettledRef.current = true;
prevLengthRef.current = messages.length;
scrollNode.scrollTop = scrollNode.scrollHeight;
wasNearBottomRef.current = true;
}, [messages.length]);
// 新消息 → 滚到底部
useEffect(() => {
if (messages.length !== prevLengthRef.current) {
const scrollNode = scrollRef.current;
const shouldSkipAutoScroll =
restoreStateRef.current === "checking" ||
restoreStateRef.current === "restoring" ||
restoreStateRef.current === "settlingBottom";
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
if (scrollNode && wasNearBottomRef.current) {
scrollNode.scrollTo({
top: scrollNode.scrollHeight,
behavior: "smooth",
@@ -86,72 +80,6 @@ export function ChatArea({
}
}, [messages.length]);
useLayoutEffect(() => {
if (restoredScrollRef.current || messages.length === 0) return;
const scrollNode = scrollRef.current;
if (!scrollNode) return;
let cancelled = false;
let stopStableScrollTask: (() => void) | null = null;
restoreStateRef.current = "checking";
const restore = async () => {
const snapshot = await consumeChatScrollSnapshot();
if (cancelled) return;
if (snapshot === null) {
restoredScrollRef.current = true;
restoreStateRef.current = "settlingBottom";
prevLengthRef.current = messages.length;
stopStableScrollTask = startStableScrollTask({
scrollNode,
applyScroll: () => {
scrollNode.scrollTop = scrollNode.scrollHeight;
},
onFinish: () => {
restoreStateRef.current = "done";
wasNearBottomRef.current = true;
},
});
return;
}
restoredScrollRef.current = true;
restoreStateRef.current = "restoring";
prevLengthRef.current = messages.length;
stopStableScrollTask = startStableScrollTask({
scrollNode,
applyScroll: () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
},
onFinish: () => {
restoreStateRef.current = "done";
wasNearBottomRef.current = isNearBottom(scrollNode);
},
});
};
void restore();
return () => {
cancelled = true;
stopStableScrollTask?.();
};
}, [messages.length]);
useEffect(() => {
const stopListening = listenChatScrollSnapshotSave(({ anchorMessageId }) => {
saveCurrentScrollSnapshotNow(anchorMessageId);
});
return () => {
stopListening();
};
}, []);
return (
<main
ref={scrollRef}
@@ -170,6 +98,7 @@ export function ChatArea({
unlockingMessageId,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onOpenImage,
)}
{isReplyingAI && <LottieMessageBubble />}
@@ -183,47 +112,6 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
}
function startStableScrollTask({
applyScroll,
onFinish,
scrollNode,
}: {
applyScroll: () => void;
onFinish: () => void;
scrollNode: HTMLElement;
}): () => void {
let frame: number | null = null;
let earlyTimer: number | null = null;
let lateTimer: number | null = null;
let observerTimer: number | null = null;
let finishTimer: number | null = null;
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(applyScroll);
applyScroll();
frame = window.requestAnimationFrame(applyScroll);
earlyTimer = window.setTimeout(applyScroll, 120);
lateTimer = window.setTimeout(applyScroll, 600);
Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child);
});
observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect();
}, 800);
finishTimer = window.setTimeout(onFinish, 850);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
if (earlyTimer !== null) window.clearTimeout(earlyTimer);
if (lateTimer !== null) window.clearTimeout(lateTimer);
if (observerTimer !== null) window.clearTimeout(observerTimer);
if (finishTimer !== null) window.clearTimeout(finishTimer);
resizeObserver?.disconnect();
};
}
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
@@ -232,6 +120,7 @@ function renderMessagesWithDateHeaders(
unlockingMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: (messageId: string) => void,
onOpenImage?: (messageId: string) => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -255,6 +144,7 @@ function renderMessagesWithDateHeaders(
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onOpenImage={onOpenImage}
/>
),
);
@@ -72,6 +72,7 @@ export function FullscreenImageViewer({
<div
className={`${styles.viewer} ${styles.paywallViewer}`}
role="dialog"
aria-modal="true"
aria-label="Locked fullscreen image"
>
{shouldUseNativeImage ? (
@@ -119,8 +120,17 @@ export function FullscreenImageViewer({
className={styles.viewer}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Fullscreen image"
>
<button
type="button"
className={styles.backButton}
onClick={onClose}
aria-label="Back"
>
<ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" />
</button>
{hasImageError ? (
<div className="error">🖼</div>
) : shouldUseNativeImage ? (
@@ -129,6 +139,7 @@ export function FullscreenImageViewer({
alt=""
className={styles.viewerImage}
onError={handleImageError}
onClick={(event) => event.stopPropagation()}
/>
) : (
<Image
@@ -138,6 +149,7 @@ export function FullscreenImageViewer({
height={800}
className={styles.viewerImage}
onError={handleImageError}
onClick={(event) => event.stopPropagation()}
/>
)}
</div>
+6 -8
View File
@@ -5,34 +5,33 @@
*
* 支持:
* - base64 data URI 解码(`data:image/png;base64,...`
* - 点击 → 跳转动态路由打开全屏查看器
* - 点击 → 通知聊天页打开页内全屏查看器
* - 错误占位符(broken image icon
*/
import Image from "next/image";
import { useState } from "react";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
isBrowserLocalChatImageSource,
normalizeChatImageSource,
} from "@/lib/chat/chat_media_url";
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
import { requestChatScrollSnapshotSave } from "../chat-scroll-session";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
messageId?: string;
imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
onOpenImage?: (messageId: string) => void;
}
export function ImageBubble({
messageId,
imageUrl,
imagePaywalled = false,
onOpenImage,
}: ImageBubbleProps) {
const navigator = useAppNavigator();
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
@@ -42,7 +41,7 @@ export function ImageBubble({
});
const src = normalizeChatImageSource(mediaUrl || imageUrl);
const canOpen = Boolean(messageId);
const canOpen = Boolean(messageId && onOpenImage);
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
const error = errorSrc === src;
@@ -55,9 +54,8 @@ export function ImageBubble({
};
const openImage = () => {
if (!messageId) return;
requestChatScrollSnapshotSave(messageId);
navigator.openChatImage(messageId);
if (!messageId || !onOpenImage) return;
onOpenImage(messageId);
};
return (
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
export function MessageBubble({
@@ -44,6 +45,7 @@ export function MessageBubble({
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onOpenImage,
}: MessageBubbleProps) {
const { avatarUrl } = useUserState();
@@ -70,6 +72,7 @@ export function MessageBubble({
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
@@ -97,6 +100,7 @@ export function MessageBubble({
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
@@ -19,6 +19,7 @@ export interface MessageContentProps {
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
}
const IMAGE_PLACEHOLDER = "[图片]";
@@ -37,6 +38,7 @@ export function MessageContent({
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
onOpenImage,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -74,6 +76,7 @@ export function MessageContent({
messageId={messageId}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onOpenImage={onOpenImage}
/>
)}
{hasAudio && audioUrl ? (
@@ -19,6 +19,8 @@ export interface UseChatUnlockNavigationFlowInput {
returnUrl: string;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
enabled?: boolean;
}
export interface UseChatUnlockNavigationFlowOutput {
@@ -35,6 +37,8 @@ export function useChatUnlockNavigationFlow({
returnUrl,
expectedKind,
expectedMessageId,
ignoredKind,
enabled = true,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator();
const userState = useUserState();
@@ -44,9 +48,12 @@ export function useChatUnlockNavigationFlow({
request: chatState.unlockPaywallRequest,
expectedKind,
expectedMessageId,
ignoredKind,
enabled,
});
useEffect(() => {
if (!enabled) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
let cancelled = false;
@@ -83,8 +90,10 @@ export function useChatUnlockNavigationFlow({
}, [
chatDispatch,
chatState.historyLoaded,
enabled,
expectedKind,
expectedMessageId,
ignoredKind,
navigator.isAuthenticatedUser,
returnUrl,
]);
@@ -135,9 +144,19 @@ function getScopedUnlockPaywallRequest(input: {
request: ChatUnlockPaywallRequest | null;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
ignoredKind?: PendingChatUnlockKind;
enabled?: boolean;
}): ChatUnlockPaywallRequest | null {
const { request, expectedKind, expectedMessageId } = input;
const {
request,
expectedKind,
expectedMessageId,
ignoredKind,
enabled = true,
} = input;
if (!enabled) return null;
if (!request) return null;
if (ignoredKind && request.kind === ignoredKind) return null;
if (expectedKind && request.kind !== expectedKind) return null;
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
return request;
@@ -1,37 +0,0 @@
.emptyScreen {
display: flex;
min-height: var(--app-viewport-height, 100dvh);
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--page-section-gap, 18px);
padding:
calc(var(--page-padding-y, 24px) + var(--app-safe-top, 0px))
calc(var(--page-padding-x, 24px) + var(--app-safe-right, 0px))
calc(var(--page-padding-y, 24px) + var(--app-safe-bottom, 0px))
calc(var(--page-padding-x, 24px) + var(--app-safe-left, 0px));
background: #0d0b14;
color: #ffffff;
}
.backButton {
min-height: var(--responsive-control-height, 44px);
padding: 0 var(--responsive-card-padding, 18px);
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #191316;
cursor: pointer;
font: inherit;
font-size: var(--responsive-body, 15px);
font-weight: 800;
}
.emptyText {
margin: 0;
color: rgba(255, 255, 255, 0.78);
font-size: var(--responsive-body, 16px);
font-weight: 700;
text-align: center;
}
@@ -1,95 +0,0 @@
"use client";
import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatState } from "@/stores/chat/chat-context";
import {
ChatUnlockDialogs,
FullscreenImageViewer,
} from "../../components";
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
import styles from "./chat-image-viewer-screen.module.css";
export interface ChatImageViewerScreenProps {
messageId: string;
}
export function ChatImageViewerScreen({
messageId,
}: ChatImageViewerScreenProps) {
const navigator = useAppNavigator();
const chatState = useChatState();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({
returnUrl,
expectedKind: "image",
expectedMessageId: messageId,
});
const message = chatState.messages.find(
(item) => item.id === messageId && item.imageUrl,
);
const handleClose = () => {
navigator.openChat({ scroll: false });
};
const handleUnlockImagePaywall = () => {
requestMessageUnlock(messageId, "image");
};
const unlockDialogs = (
<ChatUnlockDialogs
unlockPaywallRequest={unlockPaywallRequest}
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
);
if (!message) {
return (
<>
<MobileShell background="#0d0b14">
<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>
</MobileShell>
{unlockDialogs}
</>
);
}
return (
<>
<MobileShell background="#000000">
<FullscreenImageViewer
messageId={messageId}
imageUrl={message.imageUrl ?? ""}
imagePaywalled={message.imagePaywalled === true}
isUnlockingImagePaywall={
chatState.isUnlockingMessage &&
chatState.unlockingMessageId === messageId
}
onUnlockImagePaywall={handleUnlockImagePaywall}
onClose={handleClose}
/>
</MobileShell>
{unlockDialogs}
</>
);
}
-8
View File
@@ -1,8 +0,0 @@
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} />;
}
+25 -1
View File
@@ -1,7 +1,31 @@
"use client";
import { Suspense } from "react";
import { MobileShell } from "@/app/_components/core";
import { ChatScreen } from "@/app/chat/chat-screen";
export default function ChatPage() {
return <ChatScreen />;
return (
<Suspense fallback={<ChatFallback />}>
<ChatScreen />
</Suspense>
);
}
function ChatFallback() {
return (
<MobileShell background="#111111">
<div
style={{
minHeight: "60vh",
display: "grid",
placeItems: "center",
color: "rgba(255, 255, 255, 0.72)",
}}
>
Loading chat...
</div>
</MobileShell>
);
}