From fc92c3a5d56c0c9cc2093b9c33d16b27d8f5c6f7 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 9 Jul 2026 10:11:25 +0800 Subject: [PATCH] refactor(chat): show images in page overlay --- .../__tests__/chat-image-overlay-url.test.ts | 37 ++++ .../__tests__/chat-scroll-session.test.ts | 116 ------------- src/app/chat/chat-image-overlay-url.ts | 26 +++ src/app/chat/chat-screen.tsx | 91 +++++++++- src/app/chat/chat-scroll-session.ts | 163 ------------------ src/app/chat/components/chat-area.tsx | 140 ++------------- .../components/fullscreen-image-viewer.tsx | 12 ++ src/app/chat/components/image-bubble.tsx | 14 +- src/app/chat/components/message-bubble.tsx | 4 + src/app/chat/components/message-content.tsx | 3 + .../hooks/use-chat-unlock-navigation-flow.ts | 21 ++- .../chat-image-viewer-screen.module.css | 37 ---- .../[messageId]/chat-image-viewer-screen.tsx | 95 ---------- src/app/chat/image/[messageId]/page.tsx | 8 - src/app/chat/page.tsx | 26 ++- .../__tests__/navigation_storage.test.ts | 8 +- src/data/storage/storage_keys.ts | 1 - src/lib/chat/chat_scroll_session_storage.ts | 48 ------ .../__tests__/subscription_exit.test.ts | 12 +- src/router/__tests__/route-meta.test.ts | 5 +- src/router/route-meta.ts | 1 - src/router/routes.ts | 5 +- src/router/use-app-navigator.ts | 7 - 23 files changed, 253 insertions(+), 627 deletions(-) create mode 100644 src/app/chat/__tests__/chat-image-overlay-url.test.ts delete mode 100644 src/app/chat/__tests__/chat-scroll-session.test.ts create mode 100644 src/app/chat/chat-image-overlay-url.ts delete mode 100644 src/app/chat/chat-scroll-session.ts delete mode 100644 src/app/chat/image/[messageId]/chat-image-viewer-screen.module.css delete mode 100644 src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx delete mode 100644 src/app/chat/image/[messageId]/page.tsx delete mode 100644 src/lib/chat/chat_scroll_session_storage.ts diff --git a/src/app/chat/__tests__/chat-image-overlay-url.test.ts b/src/app/chat/__tests__/chat-image-overlay-url.test.ts new file mode 100644 index 00000000..e903c8a2 --- /dev/null +++ b/src/app/chat/__tests__/chat-image-overlay-url.test.ts @@ -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"); + }); +}); diff --git a/src/app/chat/__tests__/chat-scroll-session.test.ts b/src/app/chat/__tests__/chat-scroll-session.test.ts deleted file mode 100644 index da835a3c..00000000 --- a/src/app/chat/__tests__/chat-scroll-session.test.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/app/chat/chat-image-overlay-url.ts b/src/app/chat/chat-image-overlay-url.ts new file mode 100644 index 00000000..2f0ea99e --- /dev/null +++ b/src/app/chat/chat-image-overlay-url.ts @@ -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; +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 4433d3c5..edc4a3b6 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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 (
@@ -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} /> + + + {selectedImageMessage?.imageUrl ? ( + + ) : null}
); diff --git a/src/app/chat/chat-scroll-session.ts b/src/app/chat/chat-scroll-session.ts deleted file mode 100644 index a7054e53..00000000 --- a/src/app/chat/chat-scroll-session.ts +++ /dev/null @@ -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(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; - 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 { - 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; - 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("[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); -} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 2c13d681..a19a52de 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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(null); const prevLengthRef = useRef(messages.length); - const restoredScrollRef = useRef(false); - const restoreStateRef = useRef("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 (
} @@ -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} /> ), ); diff --git a/src/app/chat/components/fullscreen-image-viewer.tsx b/src/app/chat/components/fullscreen-image-viewer.tsx index 2cf8abb5..228e7d61 100644 --- a/src/app/chat/components/fullscreen-image-viewer.tsx +++ b/src/app/chat/components/fullscreen-image-viewer.tsx @@ -72,6 +72,7 @@ export function FullscreenImageViewer({
{shouldUseNativeImage ? ( @@ -119,8 +120,17 @@ export function FullscreenImageViewer({ className={styles.viewer} onClick={onClose} role="dialog" + aria-modal="true" aria-label="Fullscreen image" > + {hasImageError ? (
🖼
) : shouldUseNativeImage ? ( @@ -129,6 +139,7 @@ export function FullscreenImageViewer({ alt="" className={styles.viewerImage} onError={handleImageError} + onClick={(event) => event.stopPropagation()} /> ) : ( event.stopPropagation()} /> )}
diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index 1ae37fbd..11a1cbeb 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -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(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 ( diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index d1a7dc9c..16fddf34 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -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} /> @@ -97,6 +100,7 @@ export function MessageBubble({ isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} + onOpenImage={onOpenImage} />