refactor(chat): show images in page overlay
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user