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
+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 ? (